1 /**
2  * Cached HTTP responses
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <vladimir@thecybershadow.net>
12  */
13 
14 module ae.net.http.caching;
15 
16 import std.datetime;
17 
18 import ae.net.http.common;
19 import ae.net.http.responseex;
20 import ae.sys.data;
21 import ae.utils.mime;
22 import ae.utils.time;
23 import ae.utils.zlib;
24 import ae.utils.gzip;
25 alias zlib = ae.utils.zlib;
26 
27 /// Controls which caching headers are sent to clients.
28 enum CachePolicy
29 {
30 	/// No caching headers are sent.
31 	/// Usually results in occasional If-Modified-Since / 304 Not Modified checks.
32 	unspecified,
33 
34 	/// Data at this URL will never change.
35 	forever,
36 
37 	/// Disable caching (use for frequently changing content).
38 	disable
39 }
40 
41 /// Abstract class for caching resources in memory.
42 /// Stores compressed version as well.
43 class AbstractCachedResource
44 {
45 	/// Zlib compression level
46 	int compressionLevel = 1;
47 
48 protected: // interface with descendant classes
49 
50 	/// Get uncompressed data. The call may be expensive,
51 	/// result is cached (in uncompressedData).
52 	abstract Data[] getData();
53 
54 	/// Return last modified time.
55 	/// Used for Last-Modified and If-Modified-Since headers.
56 	/// Called when cached data is invalidated; return value is cached.
57 	/// Returns current (invalidation) time by default.
58 	SysTime getLastModified()
59 	{
60 		return Clock.currTime(UTC());
61 	}
62 
63 	// TODO: ETag?
64 
65 	/// Caching policy. To be set during subclass construction.
66 	CachePolicy cachePolicy = CachePolicy.unspecified;
67 
68 	/// MIME content type. To be set during subclass construction.
69 	string contentType;
70 
71 	/// Called every time a request is done.
72 	/// Can be used to check if cached data expired, and invalidate() it.
73 	/// By default is a no-op.
74 	void newRequest() {}
75 
76 	/// Clear cached uncompressed and compressed data.
77 	/// An alternative to calling invalidate() from a subclass is to
78 	/// simply create a new class instance when data becomes stale
79 	/// (overhead is negligible).
80 	final void invalidate()
81 	{
82 		uncompressedDataCache = deflateDataCache = gzipDataCache = null;
83 		lastModified = getLastModified();
84 	}
85 
86 	this()
87 	{
88 		invalidate();
89 	}
90 
91 private:
92 	Data[] uncompressedDataCache, deflateDataCache, gzipDataCache;
93 	SysTime lastModified;
94 
95 	@property final Data[] uncompressedData()
96 	{
97 		if (!uncompressedDataCache)
98 			uncompressedDataCache = getData();
99 		return uncompressedDataCache;
100 	}
101 
102 	@property final Data[] deflateData()
103 	{
104 		if (!deflateDataCache)
105 			deflateDataCache = zlib.compress(uncompressedData, zlib.ZlibOptions(compressionLevel));
106 		return deflateDataCache;
107 	}
108 
109 	@property final Data[] gzipData()
110 	{
111 		// deflate2gzip doesn't actually make a copy of the compressed data (thanks to Data[]).
112 		if (!gzipDataCache)
113 			gzipDataCache = deflate2gzip(deflateData, crc32(uncompressedData), uncompressedData.bytes.length);
114 		return gzipDataCache;
115 	}
116 
117 	// Use one response object per HTTP request (as opposed to one response
118 	// object per cached resource) to avoid problems with simultaneous HTTP
119 	// requests to the same resource (server calls .optimizeData() which
120 	// mutates the response object).
121 	final class Response : HttpResponse
122 	{
123 	protected:
124 		override void compressWithDeflate()
125 		{
126 			assert(data is uncompressedData);
127 			data = deflateData;
128 		}
129 
130 		override void compressWithGzip()
131 		{
132 			assert(data is uncompressedData);
133 			data = gzipData;
134 		}
135 	}
136 
137 public:
138 	/// Used by application code.
139 	final HttpResponse getResponse(HttpRequest request)
140 	{
141 		newRequest();
142 		auto response = new Response();
143 
144 		if ("If-Modified-Since" in request.headers)
145 		{
146 			auto clientTime = request.headers["If-Modified-Since"].parseTime!(TimeFormats.RFC2822)();
147 			auto serverTime = httpTime(lastModified)              .parseTime!(TimeFormats.RFC2822)(); // make sure to avoid any issues of fractional seconds, etc.
148 			if (serverTime <= clientTime)
149 			{
150 				response.setStatus(HttpStatusCode.NotModified);
151 				return response;
152 			}
153 		}
154 
155 		response.setStatus(HttpStatusCode.OK);
156 		response.data = uncompressedData;
157 		final switch (cachePolicy)
158 		{
159 			case CachePolicy.unspecified:
160 				break;
161 			case CachePolicy.forever:
162 				cacheForever(response.headers);
163 				break;
164 			case CachePolicy.disable:
165 				disableCache(response.headers);
166 				break;
167 		}
168 
169 		if (contentType)
170 			response.headers["Content-Type"] = contentType;
171 
172 		response.headers["Last-Modified"] = httpTime(lastModified);
173 
174 		return response;
175 	}
176 }
177 
178 /// A cached static file on disk (e.g. style, script, image).
179 // TODO: cachePolicy = forever when integrated with URL generation
180 class StaticResource : AbstractCachedResource
181 {
182 private:
183 	import std.file;
184 
185 	string filename;
186 	SysTime fileTime, lastChecked;
187 
188 	/// Don't check if the file on disk was modified more often than this interval.
189 	enum STAT_TIMEOUT = dur!"seconds"(1);
190 
191 protected:
192 	override Data[] getData()
193 	{
194 		if (!exists(filename) || !isFile(filename)) // TODO: 404
195 			throw new Exception("Static resource does not exist on disk");
196 
197 		// maybe use mmap?
198 		// mmap implies either file locking, or risk of bad data (file content changes, mapped length not)
199 
200 		import ae.sys.dataio;
201 		return [readData(filename)];
202 	}
203 
204 	override SysTime getLastModified()
205 	{
206 		return fileTime;
207 	}
208 
209 	override void newRequest()
210 	{
211 		auto now = Clock.currTime();
212 		if ((now - lastChecked) > STAT_TIMEOUT)
213 		{
214 			lastChecked = now;
215 
216 			auto newFileTime = timeLastModified(filename);
217 			if (newFileTime != fileTime)
218 			{
219 				fileTime = newFileTime;
220 				invalidate();
221 			}
222 		}
223 	}
224 
225 public:
226 	this(string filename)
227 	{
228 		this.filename = filename;
229 		contentType = guessMime(filename);
230 	}
231 }
232 
233 /// A generic cached resource, for resources that change
234 /// less often than they are requested (e.g. RSS feeds).
235 class CachedResource : AbstractCachedResource
236 {
237 private:
238 	Data[] data;
239 
240 protected:
241 	override Data[] getData()
242 	{
243 		return data;
244 	}
245 
246 public:
247 	this(Data[] data, string contentType)
248 	{
249 		this.data = data;
250 		this.contentType = contentType;
251 	}
252 
253 	void setData(Data[] data)
254 	{
255 		this.data = data;
256 		invalidate();
257 	}
258 }