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