1 module fileCache;
2 
3 import vibe.core.file;
4 import vibe.core.log;
5 import vibe.http.server;
6 import vibe.inet.message;
7 import vibe.inet.mimetypes;
8 import vibe.inet.url;
9 import vibe.stream.memory;
10 
11 import std.conv;
12 import std.datetime;
13 import std.digest.md;
14 import std..string;
15 import std.regex;
16 
17 class CacheEntry 
18 {
19 	FileInfo dirent;
20 	string pathstr;
21 	string lastModified;
22 	string etag;
23 	string cacheControl;
24 	string contentType;
25 	string contentLength;
26 	bool notFound;
27 	bool contentsTooLarge;
28 	bool isCompressedContent;
29 	ubyte[] content;
30 }
31 
32 // Max 3MB
33 immutable uint DEFAULT_CACHE_SIZE = 3*1024*1024;
34 private CacheEntry[string] entries;
35 
36 class FileServerCache
37 {
38 	private CacheEntry[string] entries;
39 	private uint remainingSize = DEFAULT_CACHE_SIZE;
40 	private uint maxSize = DEFAULT_CACHE_SIZE;
41 	private uint myMaxItemSize;
42 
43 	@property uint maxItemSize() { return myMaxItemSize; }
44 
45 	this(uint maxSize = DEFAULT_CACHE_SIZE, uint maxItemSize = DEFAULT_CACHE_SIZE)
46 	{
47 		this.maxSize = maxSize;
48 		this.myMaxItemSize = maxItemSize;
49 	}
50 
51 	void tryPut(CacheEntry info)
52 	{
53 		auto addSize = info.dirent.size;
54 		if(addSize > myMaxItemSize) return;
55 		if(addSize > remainingSize) return;
56 
57 		// TODO: Replacement policy
58 		remainingSize -= addSize;
59 		entries[info.pathstr] = info;
60 	}
61 
62 	CacheEntry get(string pathstr)
63 	{
64 		if(auto pv = pathstr in entries)
65 		{
66 			return *pv;
67 		}
68 
69 		return null;
70 	}
71 }
72 
73 auto byteRangeExpression = ctRegex!(`^bytes=(\d+)?-(\d+)?$`);
74 
75 /**
76 Additional options for the static file server.
77 */
78 enum HTTPFileServerOption {
79 	none = 0,
80 	/// respond with 404 if a file was not found
81 	failIfNotFound = 1 << 0,
82 	/// serve index.html for directories
83 	serveIndexHTML = 1 << 1,
84 	/// default options are serveIndexHTML
85 	defaults = serveIndexHTML,
86 }
87 
88 /**
89 Configuration options for the static file server.
90 */
91 class HTTPFileServerSettings {
92 	/// Prefix of the request path to strip before looking up files
93 	string serverPathPrefix = "/";
94 
95 	/// Maximum cache age to report to the client (24 hours by default)
96 	Duration maxAge;// = hours(24);
97 
98 	/// General options
99 	HTTPFileServerOption options = HTTPFileServerOption.defaults; /// additional options
100 
101 	/// File cache
102 	FileServerCache cache = null;
103 
104 	/**
105 	Called just before headers and data are sent.
106 	Allows headers to be customized, or other custom processing to be performed.
107 
108 	Note: Any changes you make to the response, physicalPath, or anything
109 	else during this function will NOT be verified by Vibe.d for correctness.
110 	Make sure any alterations you make are complete and correct according to HTTP spec.
111 	*/
112 	void delegate(scope HTTPServerRequest req, scope HTTPServerResponse res, ref string physicalPath) preWriteCallback = null;
113 
114 	this()
115 	{
116 		// need to use the contructor because the Ubuntu 13.10 GDC cannot CTFE dur()
117 		maxAge = 0.seconds;
118 	}
119 
120 	this(string path_prefix)
121 	{
122 		this();
123 		serverPathPrefix = path_prefix;
124 	}
125 }
126 
127 private CacheEntry prepareRequestResponseInfo(string pathstr, HTTPFileServerSettings settings)
128 {
129 	auto info = new CacheEntry;
130 	info.pathstr = pathstr;
131 
132 	// return if the file does not exist
133 	if (!existsFile(pathstr)){
134 		info.notFound = true;
135 		return info;
136 	}
137 
138 	FileInfo dirent;
139 	try dirent = getFileInfo(pathstr);
140 	catch(Exception){
141 		throw new HTTPStatusException(HTTPStatus.InternalServerError, "Failed to get information for the file due to a file system error.");
142 	}
143 
144 	if (dirent.isDirectory) {
145 		if (settings.options & HTTPFileServerOption.serveIndexHTML)
146 			return prepareRequestResponseInfo(pathstr ~ "index.html", settings);
147 		logDebugV("Hit directory when serving files, ignoring: %s", pathstr);
148 		info.notFound = true;
149 		return info;
150 	}
151 
152 	info.dirent = dirent;
153 
154 	info.lastModified = toRFC822DateTimeString(dirent.timeModified.toUTC());
155 	// simple etag generation
156 	info.etag = "\"" ~ hexDigest!MD5(pathstr ~ ":" ~ info.lastModified ~ ":" ~ to!string(dirent.size)).idup ~ "\"";
157 
158 	info.contentType = getMimeTypeForFile(pathstr);
159 	info.contentLength = to!string(dirent.size);
160 
161 	info.contentsTooLarge = info.dirent.size > (settings.cache !is null ? settings.cache.maxItemSize : DEFAULT_CACHE_SIZE);
162 	if(info.contentsTooLarge) return info;
163 
164 	FileStream fil;
165 	try {
166 		fil = openFile(info.pathstr);
167 		info.content.length = cast(int)info.dirent.size;
168 		fil.read(info.content);
169 	} catch( Exception e ){
170 		// TODO: handle non-existant files differently than locked files?
171 		logDebug("Failed to open file %s: %s", info.pathstr, e.toString());
172 		info.notFound = true;
173 	}
174 	finally {
175 		fil.close();
176 	}
177 
178 	return info;
179 }
180 
181 private void sendFileCacheImpl(scope HTTPServerRequest req, scope HTTPServerResponse res, Path path, HTTPFileServerSettings settings)
182 {
183 	CacheEntry info;
184 	auto pathstr = path.toNativeString();
185 	
186 	if(settings.cache is null)
187 	{
188 		info = prepareRequestResponseInfo(pathstr, settings);
189 	} else {
190 		info = settings.cache.get(pathstr);
191 		if(info is null) { 
192 			info = prepareRequestResponseInfo(pathstr, settings);
193 			settings.cache.tryPut(info);
194 		}
195 	}
196 
197 	if(info.notFound) 
198 	{
199 		if(settings.options & HTTPFileServerOption.failIfNotFound) throw new HTTPStatusException(HTTPStatus.NotFound);
200 		res.statusCode = HTTPStatus.notFound;
201 		return;
202 	}
203 
204 	res.headers["Accept-Ranges"] = "bytes";
205 
206 	res.headers["Last-Modified"] = info.lastModified;
207 	res.headers["Etag"] = info.etag;
208 
209 	if (settings.maxAge > seconds(0)) {
210 		auto expireTime = Clock.currTime(UTC()) + settings.maxAge;
211 		res.headers["Expires"] = toRFC822DateTimeString(expireTime);
212 		res.headers["Cache-Control"] = "max-age=" ~ to!string(settings.maxAge.total!"seconds");
213 	}
214 
215 	if( auto pv = "If-Modified-Since" in req.headers ) {
216 		if( *pv == info.lastModified ) {
217 			res.statusCode = HTTPStatus.NotModified;
218 			res.writeVoidBody();
219 			return;
220 		}
221 	}
222 
223 	if( auto pv = "If-None-Match" in req.headers ) {
224 		if ( *pv == info.etag ) {
225 			res.statusCode = HTTPStatus.NotModified;
226 			res.writeVoidBody();
227 			return;
228 		}
229 	}
230 
231 	if( auto pv = "If-Unmodified-Since" in req.headers ) {
232 		if( *pv != info.lastModified ) {
233 			res.statusCode = HTTPStatus.NotModified;
234 			res.writeVoidBody();
235 			return;
236 		}
237 	}
238 
239 	if( auto pv = "If-Match" in req.headers ) {
240 		if ( *pv != info.etag ) {
241 			res.statusCode = HTTPStatus.NotModified;
242 			res.writeVoidBody();
243 			return;
244 		}
245 	}
246 
247 	res.headers["Content-Type"] = info.contentType;
248 
249 	if( auto pv = "Range" in req.headers )
250 	{
251 		auto match = matchFirst(*pv, byteRangeExpression);
252 		// both inclusive indices
253 		uint begin, end;
254 		auto p1 = match.length > 1 && match[1] != "";
255 		auto p2 = match.length > 2 && match[2] != "";
256 
257 		if(p1 || p2)
258 		{
259 			if ("Content-Encoding" in res.headers)
260 				res.headers.remove("Content-Encoding");
261 
262 			if(p1 && !p2)
263 			{
264 				begin = to!uint(match[1]);
265 				end = cast(uint)info.dirent.size - 1;
266 			}
267 			else if(!p1 && p2)
268 			{
269 				begin = cast(uint)info.dirent.size - to!uint(match[2]);
270 				end = cast(uint)info.dirent.size - 1;
271 			}
272 			else if(p1 && p2) 
273 			{
274 				begin = to!uint(match[1]);
275 				end = to!uint(match[2]);
276 			}
277 
278 			res.statusCode = HTTPStatus.partialContent;
279 			res.headers["Content-Range"] = "bytes " ~ to!string(begin) ~ "-" ~ to!string(end) ~ "/" ~ info.contentLength;
280 
281 			auto length = end - begin + 1;
282 			res.headers["Content-Length"] = to!string(length);
283 
284 			if(info.contentsTooLarge)
285 			{
286 				auto fil = openFile(info.pathstr);
287 				scope(exit) fil.close();
288 				fil.seek(begin);
289 				res.writeRawBody(fil);
290 			}
291 			else
292 			{
293 				auto stream = new MemoryStream(info.content[begin..end+1]);
294 				scope(exit) delete stream;
295 				res.writeRawBody(stream);
296 			}
297 
298 			logTrace("sent partial file %d-%d, %s!", begin, end, res.headers["Content-Type"]);
299 			return;
300 		}
301 	}
302 
303 	// avoid double-compression
304 	if ("Content-Encoding" in res.headers && isCompressedFormat(info.contentType))
305 		res.headers.remove("Content-Encoding");
306 
307 	res.headers["Content-Length"] = info.contentLength;
308 
309 	if(settings.preWriteCallback)
310 	{
311 		settings.preWriteCallback(req, res, pathstr);
312 	}
313 
314 	// for HEAD responses, stop here
315 	if( res.isHeadResponse() ){
316 		res.writeVoidBody();
317 		assert(res.headerWritten);
318 		logDebug("sent file header %d, %s!", info.dirent.size, res.headers["Content-Type"]);
319 		return;
320 	}
321 
322 	if(info.contentsTooLarge)
323 	{
324 		auto fil = openFile(info.pathstr);
325 		scope(exit) fil.close();
326 		res.writeBody(fil);
327 	}
328 	else
329 	{
330 		res.writeBody(info.content);
331 	}
332 
333 	logTrace("sent file %d, %s!", info.dirent.size, res.headers["Content-Type"]);
334 }
335 
336 
337 
338 /**
339 	Returns a request handler that serves files from the specified directory.
340 
341 	See `sendFile` for more information.
342 
343 	Params:
344 	local_path = Path to the folder to serve files from.
345 	settings = Optional settings object enabling customization of how
346 	the files get served.
347 
348 	Returns:
349 	A request delegate is returned, which is suitable for registering in
350 	a `URLRouter` or for passing to `listenHTTP`.
351 
352 	See_Also: `serveStaticFile`, `sendFile`
353 */
354 HTTPServerRequestDelegateS serveStaticFiles(Path local_path, HTTPFileServerSettings settings = null)
355 {
356 	if (!settings) settings = new HTTPFileServerSettings;
357 	if (!settings.serverPathPrefix.endsWith("/")) settings.serverPathPrefix ~= "/";
358 
359 	void callback(scope HTTPServerRequest req, scope HTTPServerResponse res)
360 	{
361 		string srv_path;
362 		if (auto pp = "pathMatch" in req.params) srv_path = *pp;
363 		else if (req.path.length > 0) srv_path = req.path;
364 		else srv_path = req.requestURL;
365 
366 		if (!srv_path.startsWith(settings.serverPathPrefix)) {
367 			logDebug("path '%s' not starting with '%s'", srv_path, settings.serverPathPrefix);
368 			return;
369 		}
370 
371 		auto rel_path = srv_path[settings.serverPathPrefix.length .. $];
372 		auto rpath = Path(rel_path);
373 		logTrace("Processing '%s'", srv_path);
374 
375 		rpath.normalize();
376 		logDebug("Path '%s' -> '%s'", rel_path, rpath.toNativeString());
377 		if (rpath.absolute) {
378 			logDebug("Path is absolute, not responding");
379 			return;
380 		} else if (!rpath.empty && rpath[0] == "..")
381 			return; // don't respond to relative paths outside of the root path
382 
383 		sendFileCacheImpl(req, res, local_path ~ rpath, settings);
384 	}
385 
386 	return &callback;
387 }
388 /// ditto
389 HTTPServerRequestDelegateS serveStaticFiles(string local_path, HTTPFileServerSettings settings = null)
390 {
391 	return serveStaticFiles(Path(local_path), settings);
392 }
393 
394 /**
395 	Returns a request handler that serves a specific file on disk.
396 
397 	See `sendFile` for more information.
398 
399 	Params:
400 	local_path = Path to the file to serve.
401 	settings = Optional settings object enabling customization of how
402 	the file gets served.
403 
404 	Returns:
405 	A request delegate is returned, which is suitable for registering in
406 	a `URLRouter` or for passing to `listenHTTP`.
407 
408 	See_Also: `serveStaticFiles`, `sendFile`
409 */
410 HTTPServerRequestDelegateS serveStaticFile(Path local_path, HTTPFileServerSettings settings = null)
411 {
412 	if (!settings) settings = new HTTPFileServerSettings;
413 	assert(settings.serverPathPrefix == "/", "serverPathPrefix is not supported for single file serving.");
414 
415 	void callback(scope HTTPServerRequest req, scope HTTPServerResponse res)
416 	{
417 		sendFileCacheImpl(req, res, local_path, settings);
418 	}
419 
420 	return &callback;
421 }
422 /// ditto
423 HTTPServerRequestDelegateS serveStaticFile(string local_path, HTTPFileServerSettings settings = null)
424 {
425 	return serveStaticFile(Path(local_path), settings);
426 }