1 /**
2 	A static HTTP file server.
3 
4 	Copyright: © 2012-2015 RejectedSoftware e.K.
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module vibe.http.fileserver;
9 
10 import vibe.core.file;
11 import vibe.core.log;
12 import vibe.core.stream : RandomAccessStream, pipe;
13 import vibe.http.server;
14 import vibe.inet.message;
15 import vibe.inet.mimetypes;
16 import vibe.inet.url;
17 import vibe.internal.interfaceproxy;
18 
19 import std.conv;
20 import std.datetime;
21 import std.digest.md;
22 import std.string;
23 import std.algorithm;
24 
25 @safe:
26 
27 
28 /**
29 	Returns a request handler that serves files from the specified directory.
30 
31 	See `sendFile` for more information.
32 
33 	Params:
34 		local_path = Path to the folder to serve files from.
35 		settings = Optional settings object enabling customization of how
36 			the files get served.
37 
38 	Returns:
39 		A request delegate is returned, which is suitable for registering in
40 		a `URLRouter` or for passing to `listenHTTP`.
41 
42 	See_Also: `serveStaticFile`, `sendFile`
43 */
44 HTTPServerRequestDelegateS serveStaticFiles(NativePath local_path, HTTPFileServerSettings settings = null)
45 {
46 	import std.range.primitives : front;
47 	if (!settings) settings = new HTTPFileServerSettings;
48 	if (!settings.serverPathPrefix.endsWith("/")) settings.serverPathPrefix ~= "/";
49 
50 	void callback(scope HTTPServerRequest req, scope HTTPServerResponse res)
51 	@safe {
52 		string srv_path;
53 		if (auto pp = "pathMatch" in req.params) srv_path = *pp;
54 		else if (req.path.length > 0) srv_path = req.path;
55 		else srv_path = req.requestURL;
56 
57 		if (!srv_path.startsWith(settings.serverPathPrefix)) {
58 			logDebug("path '%s' not starting with '%s'", srv_path, settings.serverPathPrefix);
59 			return;
60 		}
61 
62 		auto rel_path = srv_path[settings.serverPathPrefix.length .. $];
63 		auto rpath = PosixPath(rel_path);
64 		logTrace("Processing '%s'", srv_path);
65 
66 		rpath.normalize();
67 		logDebug("Path '%s' -> '%s'", rel_path, rpath.toNativeString());
68 		if (rpath.absolute) {
69 			logDebug("Path is absolute, not responding");
70 			return;
71 		} else if (!rpath.empty && rpath.bySegment.front.name == "..")
72 			return; // don't respond to relative paths outside of the root path
73 
74 		sendFileImpl(req, res, local_path ~ rpath, settings);
75 	}
76 
77 	return &callback;
78 }
79 /// ditto
80 HTTPServerRequestDelegateS serveStaticFiles(string local_path, HTTPFileServerSettings settings = null)
81 {
82 	return serveStaticFiles(NativePath(local_path), settings);
83 }
84 
85 ///
86 unittest {
87 	import vibe.http.fileserver;
88 	import vibe.http.router;
89 	import vibe.http.server;
90 
91 	void setupServer()
92 	{
93 		auto router = new URLRouter;
94 		// add other routes here
95 		router.get("*", serveStaticFiles("public/"));
96 
97 		auto settings = new HTTPServerSettings;
98 		listenHTTP(settings, router);
99 	}
100 }
101 
102 /** This example serves all files in the "public" sub directory
103 	with an added prefix "static/" so that they don't interfere
104 	with other registered routes.
105 */
106 unittest {
107 	import vibe.http.fileserver;
108 	import vibe.http.router;
109 	import vibe.http.server;
110 
111 	void setupRoutes()
112 	{
113 	 	auto router = new URLRouter;
114 		// add other routes here
115 
116 		auto fsettings = new HTTPFileServerSettings;
117 		fsettings.serverPathPrefix = "/static";
118 		router.get("static/*", serveStaticFiles("public/", fsettings));
119 
120 		auto settings = new HTTPServerSettings;
121 		listenHTTP(settings, router);
122 	}
123 }
124 
125 
126 /**
127 	Returns a request handler that serves a specific file on disk.
128 
129 	See `sendFile` for more information.
130 
131 	Params:
132 		local_path = Path to the file to serve.
133 		settings = Optional settings object enabling customization of how
134 			the file gets served.
135 
136 	Returns:
137 		A request delegate is returned, which is suitable for registering in
138 		a `URLRouter` or for passing to `listenHTTP`.
139 
140 	See_Also: `serveStaticFiles`, `sendFile`
141 */
142 HTTPServerRequestDelegateS serveStaticFile(NativePath local_path, HTTPFileServerSettings settings = null)
143 {
144 	if (!settings) settings = new HTTPFileServerSettings;
145 	assert(settings.serverPathPrefix == "/", "serverPathPrefix is not supported for single file serving.");
146 
147 	void callback(scope HTTPServerRequest req, scope HTTPServerResponse res)
148 	{
149 		sendFileImpl(req, res, local_path, settings);
150 	}
151 
152 	return &callback;
153 }
154 /// ditto
155 HTTPServerRequestDelegateS serveStaticFile(string local_path, HTTPFileServerSettings settings = null)
156 {
157 	return serveStaticFile(NativePath(local_path), settings);
158 }
159 
160 
161 /**
162 	Sends a file to the given HTTP server response object.
163 
164 	When serving a file, certain request headers are supported to avoid sending
165 	the file if the client has it already cached. These headers are
166 	`"If-Modified-Since"` and `"If-None-Match"`. The client will be delivered
167 	with the necessary `"Etag"` (generated from the path, size and last
168 	modification time of the file) and `"Last-Modified"` headers.
169 
170 	The cache control directives `"Expires"` and `"Cache-Control"` will also be
171 	emitted if the `HTTPFileServerSettings.maxAge` field is set to a positive
172 	duration.
173 
174 	Finally, HEAD requests will automatically be handled without reading the
175 	actual file contents. Am empty response body is written instead.
176 
177 	Params:
178 		req = The incoming HTTP request - cache and modification headers of the
179 			request can influence the generated response.
180 		res = The response object to write to.
181 		settings = Optional settings object enabling customization of how the
182 			file gets served.
183 */
184 void sendFile(scope HTTPServerRequest req, scope HTTPServerResponse res, NativePath path, HTTPFileServerSettings settings = null)
185 {
186 	static HTTPFileServerSettings default_settings;
187 	if (!settings) {
188 		if (!default_settings) default_settings = new HTTPFileServerSettings;
189 		settings = default_settings;
190 	}
191 
192 	sendFileImpl(req, res, path, settings);
193 }
194 
195 
196 /**
197 	Configuration options for the static file server.
198 */
199 class HTTPFileServerSettings {
200 	/// Prefix of the request path to strip before looking up files
201 	string serverPathPrefix = "/";
202 
203 	/// Maximum cache age to report to the client (24 hours by default)
204 	Duration maxAge;// = hours(24);
205 
206 	/// General options
207 	HTTPFileServerOption options = HTTPFileServerOption.defaults; /// additional options
208 
209 	/** Maps from encoding scheme (e.g. "gzip") to file extension.
210 
211 		If a request accepts a supported encoding scheme, then the file server
212 		will look for a file with the extension as a suffix and, if that exists,
213 		sends it as the encoded representation instead of sending the original
214 		file.
215 
216 		Example:
217 			---
218 			settings.encodingFileExtension["gzip"] = ".gz";
219 			---
220 	*/
221 	string[string] encodingFileExtension;
222 
223 	/**
224 		Called just before headers and data are sent.
225 		Allows headers to be customized, or other custom processing to be performed.
226 
227 		Note: Any changes you make to the response, physicalPath, or anything
228 		else during this function will NOT be verified by Vibe.d for correctness.
229 		Make sure any alterations you make are complete and correct according to HTTP spec.
230 	*/
231 	void delegate(scope HTTPServerRequest req, scope HTTPServerResponse res, ref string physicalPath) preWriteCallback = null;
232 
233 	this()
234 	{
235 		// need to use the contructor because the Ubuntu 13.10 GDC cannot CTFE dur()
236 		maxAge = 24.hours;
237 	}
238 
239 	this(string path_prefix)
240 	{
241 		this();
242 		serverPathPrefix = path_prefix;
243 	}
244 }
245 
246 
247 /**
248    Additional options for the static file server.
249  */
250 enum HTTPFileServerOption {
251 	none = 0,
252 	/// respond with 404 if a file was not found
253 	failIfNotFound = 1 << 0,
254 	/// serve index.html for directories
255 	serveIndexHTML = 1 << 1,
256 	/// default options are serveIndexHTML
257 	defaults = serveIndexHTML,
258 }
259 
260 
261 private void sendFileImpl(scope HTTPServerRequest req, scope HTTPServerResponse res, NativePath path, HTTPFileServerSettings settings = null)
262 {
263 	auto pathstr = path.toNativeString();
264 
265 	// return if the file does not exist
266 	if (!existsFile(pathstr)){
267 		if (settings.options & HTTPFileServerOption.failIfNotFound)
268 			throw new HTTPStatusException(HTTPStatus.NotFound);
269 		return;
270 	}
271 
272 	FileInfo dirent;
273 	try dirent = getFileInfo(pathstr);
274 	catch(Exception){
275 		throw new HTTPStatusException(HTTPStatus.InternalServerError, "Failed to get information for the file due to a file system error.");
276 	}
277 
278 	if (dirent.isDirectory) {
279 		if (settings.options & HTTPFileServerOption.serveIndexHTML)
280 			return sendFileImpl(req, res, path ~ "index.html", settings);
281 		logDebugV("Hit directory when serving files, ignoring: %s", pathstr);
282 		if (settings.options & HTTPFileServerOption.failIfNotFound)
283 			throw new HTTPStatusException(HTTPStatus.NotFound);
284 		return;
285 	}
286 
287 	auto lastModified = toRFC822DateTimeString(dirent.timeModified.toUTC());
288 	// simple etag generation
289 	auto etag = "\"" ~ hexDigest!MD5(pathstr ~ ":" ~ lastModified ~ ":" ~ to!string(dirent.size)).idup ~ "\"";
290 
291 	res.headers["Last-Modified"] = lastModified;
292 	res.headers["Etag"] = etag;
293 	if (settings.maxAge > seconds(0)) {
294 		auto expireTime = Clock.currTime(UTC()) + settings.maxAge;
295 		res.headers["Expires"] = toRFC822DateTimeString(expireTime);
296 		res.headers["Cache-Control"] = "max-age="~to!string(settings.maxAge.total!"seconds");
297 	}
298 
299 	if( auto pv = "If-Modified-Since" in req.headers ) {
300 		if( *pv == lastModified ) {
301 			res.statusCode = HTTPStatus.NotModified;
302 			res.writeVoidBody();
303 			return;
304 		}
305 	}
306 
307 	if( auto pv = "If-None-Match" in req.headers ) {
308 		if ( *pv == etag ) {
309 			res.statusCode = HTTPStatus.NotModified;
310 			res.writeVoidBody();
311 			return;
312 		}
313 	}
314 
315 	auto mimetype = res.headers.get("Content-Type", getMimeTypeForFile(pathstr));
316 
317 	// avoid double-compression
318 	if ("Content-Encoding" in res.headers && isCompressedFormat(mimetype))
319 		res.headers.remove("Content-Encoding");
320 
321 	if (!("Content-Type" in res.headers))
322 		res.headers["Content-Type"] = mimetype;
323 
324 	res.headers.addField("Accept-Ranges", "bytes");
325 	ulong rangeStart = 0;
326 	ulong rangeEnd = 0;
327 	auto prange = "Range" in req.headers;
328 
329 	if (prange) {
330 		auto range = (*prange).chompPrefix("bytes=");
331 		if (range.canFind(','))
332 			throw new HTTPStatusException(HTTPStatus.notImplemented);
333 		auto s = range.split("-");
334 		if (s.length != 2)
335 			throw new HTTPStatusException(HTTPStatus.badRequest);
336 		// https://tools.ietf.org/html/rfc7233
337 		// Range can be in form "-\d", "\d-" or "\d-\d"
338 		try {
339 			if (s[0].length) {
340 				rangeStart = s[0].to!ulong;
341 				rangeEnd = s[1].length ? s[1].to!ulong : dirent.size;
342 			} else if (s[1].length) {
343 				rangeEnd = dirent.size;
344 				auto len = s[1].to!ulong;
345 				if (len >= rangeEnd)
346 					rangeStart = 0;
347 				else
348 					rangeStart = rangeEnd - len;
349 			} else {
350 				throw new HTTPStatusException(HTTPStatus.badRequest);
351 			}
352 		} catch (ConvException) {
353 			throw new HTTPStatusException(HTTPStatus.badRequest);
354 		}
355 		if (rangeEnd > dirent.size)
356 			rangeEnd = dirent.size;
357 		if (rangeStart > rangeEnd)
358 			rangeStart = rangeEnd;
359 		if (rangeEnd)
360 			rangeEnd--; // End is inclusive, so one less than length
361 		// potential integer overflow with rangeEnd - rangeStart == size_t.max is intended. This only happens with empty files, the + 1 will then put it back to 0
362 		res.headers["Content-Length"] = to!string(rangeEnd - rangeStart + 1);
363 		res.headers["Content-Range"] = "bytes %s-%s/%s".format(rangeStart < rangeEnd ? rangeStart : rangeEnd, rangeEnd, dirent.size);
364 		res.statusCode = HTTPStatus.partialContent;
365 	} else
366 		res.headers["Content-Length"] = dirent.size.to!string;
367 
368 	// check for already encoded file if configured
369 	string encodedFilepath;
370 	auto pce = "Content-Encoding" in res.headers;
371 	if (pce) {
372 		if (auto pfe = *pce in settings.encodingFileExtension) {
373 			assert((*pfe).length > 0);
374 			auto p = pathstr ~ *pfe;
375 			if (existsFile(p))
376 				encodedFilepath = p;
377 		}
378 	}
379 	if (encodedFilepath.length) {
380 		auto origLastModified = dirent.timeModified.toUTC();
381 
382 		try dirent = getFileInfo(encodedFilepath);
383 		catch(Exception){
384 			throw new HTTPStatusException(HTTPStatus.InternalServerError, "Failed to get information for the file due to a file system error.");
385 		}
386 
387 		// encoded file must be younger than original else warn
388 		if (dirent.timeModified.toUTC() >= origLastModified){
389 			logTrace("Using already encoded file '%s' -> '%s'", path, encodedFilepath);
390 			path = NativePath(encodedFilepath);
391 			res.headers["Content-Length"] = to!string(dirent.size);
392 		} else {
393 			logWarn("Encoded file '%s' is older than the original '%s'. Ignoring it.", encodedFilepath, path);
394 			encodedFilepath = null;
395 		}
396 	}
397 
398 	if(settings.preWriteCallback)
399 		settings.preWriteCallback(req, res, pathstr);
400 
401 	// for HEAD responses, stop here
402 	if( res.isHeadResponse() ){
403 		res.writeVoidBody();
404 		assert(res.headerWritten);
405 		logDebug("sent file header %d, %s!", dirent.size, res.headers["Content-Type"]);
406 		return;
407 	}
408 
409 	// else write out the file contents
410 	//logTrace("Open file '%s' -> '%s'", srv_path, pathstr);
411 	FileStream fil;
412 	try {
413 		fil = openFile(path);
414 	} catch( Exception e ){
415 		// TODO: handle non-existant files differently than locked files?
416 		logDebug("Failed to open file %s: %s", pathstr, () @trusted { return e.toString(); } ());
417 		return;
418 	}
419 	scope(exit) fil.close();
420 
421 	if (prange) {
422 		fil.seek(rangeStart);
423 		fil.pipe(res.bodyWriter, rangeEnd - rangeStart + 1);
424 		logTrace("partially sent file %d-%d, %s!", rangeStart, rangeEnd, res.headers["Content-Type"]);
425 	} else {
426 		if (pce && !encodedFilepath.length)
427 			fil.pipe(res.bodyWriter);
428 		else res.writeRawBody(fil);
429 		logTrace("sent file %d, %s!", fil.size, res.headers["Content-Type"]);
430 	}
431 }