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