1 /**
2 	A static HTTP file server.
3 
4 	Copyright: © 2012-2015 Sönke Ludwig
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.ascii : isWhite;
20 import std.algorithm;
21 import std.conv;
22 import std.datetime;
23 import std.digest.md;
24 import std.exception;
25 import std.range : popFront, empty, drop;
26 import std.string;
27 import std.typecons : Flag, Yes, No;
28 
29 @safe:
30 
31 
32 /**
33 	Returns a request handler that serves files from the specified directory.
34 
35 	See `sendFile` for more information.
36 
37 	Params:
38 		local_path = Path to the folder to serve files from.
39 		settings = Optional settings object enabling customization of how
40 			the files get served.
41 
42 	Returns:
43 		A request delegate is returned, which is suitable for registering in
44 		a `URLRouter` or for passing to `listenHTTP`.
45 
46 	See_Also: `serveStaticFile`, `sendFile`
47 */
48 HTTPServerRequestDelegateS serveStaticFiles(NativePath local_path, HTTPFileServerSettings settings = null)
49 {
50 	import std.range.primitives : front;
51 	if (!settings) settings = new HTTPFileServerSettings;
52 	if (!settings.serverPathPrefix.endsWith("/")) settings.serverPathPrefix ~= "/";
53 
54 	void callback(scope HTTPServerRequest req, scope HTTPServerResponse res)
55 	@safe {
56 		string srv_path;
57 		if (auto pp = "pathMatch" in req.params) srv_path = *pp;
58 		else if (req.path.length > 0) srv_path = req.path;
59 		else srv_path = req.requestURL;
60 
61 		if (!srv_path.startsWith(settings.serverPathPrefix)) {
62 			logDebug("path '%s' not starting with '%s'", srv_path, settings.serverPathPrefix);
63 			return;
64 		}
65 
66 		auto rel_path = srv_path[settings.serverPathPrefix.length .. $];
67 		auto rpath = PosixPath(rel_path);
68 		logTrace("Processing '%s'", srv_path);
69 
70 		rpath.normalize();
71 		logDebug("Path '%s' -> '%s'", rel_path, rpath.toNativeString());
72 		if (rpath.absolute) {
73 			logDebug("Path is absolute, not responding");
74 			return;
75 		} else if (!rpath.empty && rpath.bySegment.front.name == "..")
76 			return; // don't respond to relative paths outside of the root path
77 
78 		sendFileImpl(req, res, local_path ~ rpath, settings);
79 	}
80 
81 	return &callback;
82 }
83 /// ditto
84 HTTPServerRequestDelegateS serveStaticFiles(string local_path, HTTPFileServerSettings settings = null)
85 {
86 	return serveStaticFiles(NativePath(local_path), settings);
87 }
88 
89 ///
90 unittest {
91 	import vibe.http.fileserver;
92 	import vibe.http.router;
93 	import vibe.http.server;
94 
95 	void setupServer()
96 	{
97 		auto router = new URLRouter;
98 		// add other routes here
99 		router.get("*", serveStaticFiles("public/"));
100 
101 		auto settings = new HTTPServerSettings;
102 		listenHTTP(settings, router);
103 	}
104 }
105 
106 /** This example serves all files in the "public" sub directory
107 	with an added prefix "static/" so that they don't interfere
108 	with other registered routes.
109 */
110 unittest {
111 	import vibe.http.fileserver;
112 	import vibe.http.router;
113 	import vibe.http.server;
114 
115 	void setupRoutes()
116 	{
117 	 	auto router = new URLRouter;
118 		// add other routes here
119 
120 		auto fsettings = new HTTPFileServerSettings;
121 		fsettings.serverPathPrefix = "/static";
122 		router.get("/static/*", serveStaticFiles("public/", fsettings));
123 
124 		auto settings = new HTTPServerSettings;
125 		listenHTTP(settings, router);
126 	}
127 }
128 
129 
130 /**
131 	Returns a request handler that serves a specific file on disk.
132 
133 	See `sendFile` for more information.
134 
135 	Params:
136 		local_path = Path to the file to serve.
137 		settings = Optional settings object enabling customization of how
138 			the file gets served.
139 
140 	Returns:
141 		A request delegate is returned, which is suitable for registering in
142 		a `URLRouter` or for passing to `listenHTTP`.
143 
144 	See_Also: `serveStaticFiles`, `sendFile`
145 */
146 HTTPServerRequestDelegateS serveStaticFile(NativePath local_path, HTTPFileServerSettings settings = null)
147 {
148 	if (!settings) settings = new HTTPFileServerSettings;
149 	assert(settings.serverPathPrefix == "/", "serverPathPrefix is not supported for single file serving.");
150 
151 	void callback(scope HTTPServerRequest req, scope HTTPServerResponse res)
152 	{
153 		sendFileImpl(req, res, local_path, settings);
154 	}
155 
156 	return &callback;
157 }
158 /// ditto
159 HTTPServerRequestDelegateS serveStaticFile(string local_path, HTTPFileServerSettings settings = null)
160 {
161 	return serveStaticFile(NativePath(local_path), settings);
162 }
163 
164 
165 /**
166 	Sends a file to the given HTTP server response object.
167 
168 	When serving a file, certain request headers are supported to avoid sending
169 	the file if the client has it already cached. These headers are
170 	`"If-Modified-Since"` and `"If-None-Match"`. The client will be delivered
171 	with the necessary `"Etag"` (generated from size and last modification time
172 	of the file) and `"Last-Modified"` headers.
173 
174 	The cache control directives `"Expires"` and/or `"Cache-Control"` will also be
175 	emitted if the `HTTPFileServerSettings.maxAge` field is set to a positive
176 	duration and/or `HTTPFileServerSettings.cacheControl` has been set.
177 
178 	Finally, HEAD requests will automatically be handled without reading the
179 	actual file contents. Am empty response body is written instead.
180 
181 	Params:
182 		req = The incoming HTTP request - cache and modification headers of the
183 			request can influence the generated response.
184 		res = The response object to write to.
185 		path = Path to the file to be sent.
186 		settings = Optional settings object enabling customization of how the
187 			file gets served.
188 */
189 void sendFile(scope HTTPServerRequest req, scope HTTPServerResponse res, NativePath path, HTTPFileServerSettings settings = null)
190 {
191 	static HTTPFileServerSettings default_settings;
192 	if (!settings) {
193 		if (!default_settings) default_settings = new HTTPFileServerSettings;
194 		settings = default_settings;
195 	}
196 
197 	sendFileImpl(req, res, path, settings);
198 }
199 
200 
201 /**
202 	Configuration options for the static file server.
203 */
204 class HTTPFileServerSettings {
205 	/// Prefix of the request path to strip before looking up files
206 	string serverPathPrefix = "/";
207 
208 	/// Maximum cache age to report to the client (zero by default)
209 	Duration maxAge = 0.seconds;
210 
211 	/** Cache control to control where cache can be saved, if at all, such as
212 		proxies, the storage, etc.
213 
214 		Leave null or empty to not emit any cache control directives other than
215 		max-age if maxAge is set.
216 
217 		Common values include: public for making a shared resource cachable across
218 		multiple users or private for a response that should only be cached for a
219 		single user.
220 
221 		See https://developer.mozilla.org/de/docs/Web/HTTP/Headers/Cache-Control
222 	*/
223 	string cacheControl = null;
224 
225 	/// General options
226 	HTTPFileServerOption options = HTTPFileServerOption.defaults; /// additional options
227 
228 	/** Maps from encoding scheme (e.g. "gzip") to file extension.
229 
230 		If a request accepts a supported encoding scheme, then the file server
231 		will look for a file with the extension as a suffix and, if that exists,
232 		sends it as the encoded representation instead of sending the original
233 		file.
234 
235 		Example:
236 			---
237 			settings.encodingFileExtension["gzip"] = ".gz";
238 			---
239 	*/
240 	string[string] encodingFileExtension;
241 
242 	/**
243 		Called just before headers and data are sent.
244 		Allows headers to be customized, or other custom processing to be performed.
245 
246 		Note: Any changes you make to the response, physicalPath, or anything
247 		else during this function will NOT be verified by Vibe.d for correctness.
248 		Make sure any alterations you make are complete and correct according to HTTP spec.
249 	*/
250 	void delegate(scope HTTPServerRequest req, scope HTTPServerResponse res, ref string physicalPath) preWriteCallback = null;
251 
252 	this()
253 	{
254 	}
255 
256 	this(string path_prefix)
257 	{
258 		this();
259 		serverPathPrefix = path_prefix;
260 	}
261 }
262 
263 
264 /**
265    Additional options for the static file server.
266  */
267 enum HTTPFileServerOption {
268 	none = 0,
269 	/// respond with 404 if a file was not found
270 	failIfNotFound = 1 << 0,
271 	/// serve index.html for directories
272 	serveIndexHTML = 1 << 1,
273 	/// default options are serveIndexHTML
274 	defaults = serveIndexHTML,
275 }
276 
277 
278 private void sendFileImpl(scope HTTPServerRequest req, scope HTTPServerResponse res, NativePath path, HTTPFileServerSettings settings = null)
279 {
280 	auto pathstr = path.toNativeString();
281 
282 	// return if the file does not exist
283 	if (!existsFile(pathstr)){
284 		if (settings.options & HTTPFileServerOption.failIfNotFound)
285 			throw new HTTPStatusException(HTTPStatus.notFound);
286 		return;
287 	}
288 
289 	FileInfo dirent;
290 	try dirent = getFileInfo(pathstr);
291 	catch(Exception){
292 		throw new HTTPStatusException(HTTPStatus.internalServerError, "Failed to get information for the file due to a file system error.");
293 	}
294 
295 	if (dirent.isDirectory) {
296 		if (settings.options & HTTPFileServerOption.serveIndexHTML)
297 			return sendFileImpl(req, res, path ~ "index.html", settings);
298 		logDebugV("Hit directory when serving files, ignoring: %s", pathstr);
299 		if (settings.options & HTTPFileServerOption.failIfNotFound)
300 			throw new HTTPStatusException(HTTPStatus.notFound);
301 		return;
302 	}
303 
304 	if (handleCacheFile(req, res, dirent, settings.cacheControl, settings.maxAge)) {
305 		return;
306 	}
307 
308 	auto mimetype = res.headers.get("Content-Type", getMimeTypeForFile(pathstr));
309 
310 	// avoid double-compression
311 	if ("Content-Encoding" in res.headers && isCompressedFormat(mimetype))
312 		res.headers.remove("Content-Encoding");
313 
314 	if (!("Content-Type" in res.headers))
315 		res.headers["Content-Type"] = mimetype;
316 
317 	res.headers.addField("Accept-Ranges", "bytes");
318 	RangeSpec range;
319 	if (auto prange = "Range" in req.headers) {
320 		range = parseRangeHeader(*prange, dirent.size, res);
321 
322 		// 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
323 		res.headers["Content-Length"] = to!string(range.max - range.min);
324 		res.headers["Content-Range"] = "bytes %s-%s/%s".format(range.min, range.max - 1, dirent.size);
325 		res.statusCode = HTTPStatus.partialContent;
326 	} else res.headers["Content-Length"] = dirent.size.to!string;
327 
328 	// check for already encoded file if configured
329 	string encodedFilepath;
330 	auto pce = "Content-Encoding" in res.headers;
331 	if (pce) {
332 		if (auto pfe = *pce in settings.encodingFileExtension) {
333 			assert((*pfe).length > 0);
334 			auto p = pathstr ~ *pfe;
335 			if (existsFile(p))
336 				encodedFilepath = p;
337 		}
338 	}
339 	if (encodedFilepath.length) {
340 		auto origLastModified = dirent.timeModified.toUTC();
341 
342 		try dirent = getFileInfo(encodedFilepath);
343 		catch(Exception){
344 			throw new HTTPStatusException(HTTPStatus.internalServerError, "Failed to get information for the file due to a file system error.");
345 		}
346 
347 		// encoded file must be younger than original else warn
348 		if (dirent.timeModified.toUTC() >= origLastModified){
349 			logTrace("Using already encoded file '%s' -> '%s'", path, encodedFilepath);
350 			path = NativePath(encodedFilepath);
351 			res.headers["Content-Length"] = to!string(dirent.size);
352 		} else {
353 			logWarn("Encoded file '%s' is older than the original '%s'. Ignoring it.", encodedFilepath, path);
354 			encodedFilepath = null;
355 		}
356 	}
357 
358 	if(settings.preWriteCallback)
359 		settings.preWriteCallback(req, res, pathstr);
360 
361 	// for HEAD responses, stop here
362 	if( res.isHeadResponse() ){
363 		res.writeVoidBody();
364 		assert(res.headerWritten);
365 		logDebug("sent file header %d, %s!", dirent.size, res.headers["Content-Type"]);
366 		return;
367 	}
368 
369 	// else write out the file contents
370 	//logTrace("Open file '%s' -> '%s'", srv_path, pathstr);
371 	FileStream fil;
372 	try {
373 		fil = openFile(path);
374 	} catch( Exception e ){
375 		// TODO: handle non-existant files differently than locked files?
376 		logDebug("Failed to open file %s: %s", pathstr, () @trusted { return e.toString(); } ());
377 		return;
378 	}
379 	scope(exit) fil.close();
380 
381 	if (range.max > range.min) {
382 		fil.seek(range.min);
383 		fil.pipe(res.bodyWriter, range.max - range.min);
384 		logTrace("partially sent file %d-%d, %s!", range.min, range.max - 1, res.headers["Content-Type"]);
385 	} else {
386 		if (pce && !encodedFilepath.length)
387 			fil.pipe(res.bodyWriter);
388 		else res.writeRawBody(fil);
389 		logTrace("sent file %d, %s!", fil.size, res.headers["Content-Type"]);
390 	}
391 }
392 
393 /**
394 	Calls $(D handleCache) with prefilled etag and lastModified value based on a file.
395 
396 	See_Also: handleCache
397 
398 	Returns: $(D true) if the cache was already handled and no further response must be sent or $(D false) if a response must be sent.
399 */
400 bool handleCacheFile(scope HTTPServerRequest req, scope HTTPServerResponse res,
401 		string file, string cache_control = null, Duration max_age = Duration.zero)
402 {
403 	return handleCacheFile(req, res, NativePath(file), cache_control, max_age);
404 }
405 
406 /// ditto
407 bool handleCacheFile(scope HTTPServerRequest req, scope HTTPServerResponse res,
408 		NativePath file, string cache_control = null, Duration max_age = Duration.zero)
409 {
410 	if (!existsFile(file)) {
411 		return false;
412 	}
413 
414 	FileInfo ent;
415 	try {
416 		ent = getFileInfo(file);
417 	} catch (Exception) {
418 		throw new HTTPStatusException(HTTPStatus.internalServerError,
419 				"Failed to get information for the file due to a file system error.");
420 	}
421 
422 	return handleCacheFile(req, res, ent, cache_control, max_age);
423 }
424 
425 /// ditto
426 bool handleCacheFile(scope HTTPServerRequest req, scope HTTPServerResponse res,
427 		FileInfo dirent, string cache_control = null, Duration max_age = Duration.zero)
428 {
429 	import std.bitmanip : nativeToLittleEndian;
430 	import std.digest.md : MD5, toHexString;
431 
432 	SysTime lastModified = dirent.timeModified;
433 	const weak = cast(Flag!"weak") dirent.isDirectory;
434 	auto etag = ETag.md5(weak, lastModified.stdTime.nativeToLittleEndian, dirent.size.nativeToLittleEndian);
435 
436 	return handleCache(req, res, etag, lastModified, cache_control, max_age);
437 }
438 
439 /**
440 	Processes header tags in a request and writes responses given on requested cache status.
441 
442 	Params:
443 		req = the client request used to determine cache control flow.
444 		res = the response to write cache headers to.
445 		etag = if set to anything except .init, adds a Etag header to the response and enables handling of If-Match and If-None-Match cache control request headers.
446 		last_modified = if set to anything except .init, adds a Last-Modified header to the response and enables handling of If-Modified-Since and If-Unmodified-Since cache control request headers.
447 		cache_control = if set, adds or modifies the Cache-Control header in the response to this string. Might get an additional max-age value appended if max_age is set.
448 		max_age = optional duration to set the Expires header and Cache-Control max-age part to. (if no existing `max-age=` part is given in the cache_control parameter)
449 
450 	Returns: $(D true) if the cache was already handled and no further response must be sent or $(D false) if a response must be sent.
451 */
452 bool handleCache(scope HTTPServerRequest req, scope HTTPServerResponse res, ETag etag,
453 		SysTime last_modified, string cache_control = null, Duration max_age = Duration.zero)
454 {
455 	// https://tools.ietf.org/html/rfc7232#section-4.1
456 	// and
457 	// https://tools.ietf.org/html/rfc7232#section-6
458 	string lastModifiedString;
459 	if (last_modified != SysTime.init) {
460 		lastModifiedString = toRFC822DateTimeString(last_modified);
461 		res.headers["Last-Modified"] = lastModifiedString;
462 	}
463 
464 	if (etag != ETag.init) {
465 		res.headers["Etag"] = etag.toString;
466 	}
467 
468 	if (max_age > Duration.zero) {
469 		res.headers["Expires"] = toRFC822DateTimeString(Clock.currTime(UTC()) + max_age);
470 	}
471 
472 	if (cache_control.length) {
473 		if (max_age > Duration.zero && !cache_control.canFind("max-age=")) {
474 			res.headers["Cache-Control"] = cache_control
475 				~ ", max-age=" ~ to!string(max_age.total!"seconds");
476 		} else {
477 			res.headers["Cache-Control"] = cache_control;
478 		}
479 	} else if (max_age > Duration.zero) {
480 		res.headers["Cache-Control"] = text("max-age=", max_age.total!"seconds");
481 	}
482 
483 	// https://tools.ietf.org/html/rfc7232#section-3.1
484 	string ifMatch = req.headers.get("If-Match");
485 	if (ifMatch.length) {
486 		if (!cacheMatch(ifMatch, etag, No.allowWeak)) {
487 			res.statusCode = HTTPStatus.preconditionFailed;
488 			res.writeVoidBody();
489 			return true;
490 		}
491 	}
492 	else if (last_modified != SysTime.init) {
493 		// https://tools.ietf.org/html/rfc7232#section-3.4
494 		string ifUnmodifiedSince = req.headers.get("If-Unmodified-Since");
495 		if (ifUnmodifiedSince.length) {
496 			const check = lastModifiedString != ifUnmodifiedSince
497 				|| last_modified > parseRFC822DateTimeString(ifUnmodifiedSince);
498 			if (check) {
499 				res.statusCode = HTTPStatus.preconditionFailed;
500 				res.writeVoidBody();
501 				return true;
502 			}
503 		}
504 	}
505 
506 	// https://tools.ietf.org/html/rfc7232#section-3.2
507 	string ifNoneMatch = req.headers.get("If-None-Match");
508 	if (ifNoneMatch.length) {
509 		if (cacheMatch(ifNoneMatch, etag, Yes.allowWeak)) {
510 			if (req.method.among!(HTTPMethod.GET, HTTPMethod.HEAD))
511 				res.statusCode = HTTPStatus.notModified;
512 			else
513 				res.statusCode = HTTPStatus.preconditionFailed;
514 			res.writeVoidBody();
515 			return true;
516 		}
517 	}
518 	else if (last_modified != SysTime.init && req.method.among!(HTTPMethod.GET, HTTPMethod.HEAD)) {
519 		// https://tools.ietf.org/html/rfc7232#section-3.3
520 		string ifModifiedSince = req.headers.get("If-Modified-Since");
521 		if (ifModifiedSince.length) {
522 			const check = lastModifiedString == ifModifiedSince ||
523 				last_modified <= parseRFC822DateTimeString(ifModifiedSince);
524 			if (check) {
525 				res.statusCode = HTTPStatus.notModified;
526 				res.writeVoidBody();
527 				return true;
528 			}
529 		}
530 	}
531 
532 	// TODO: support If-Range here
533 
534 	return false;
535 }
536 
537 /**
538 	Represents an Entity-Tag value for use inside HTTP Cache headers.
539 
540 	Standards: https://tools.ietf.org/html/rfc7232#section-2.3
541 */
542 struct ETag
543 {
544 	bool weak;
545 	string tag;
546 
547 	static ETag parse(string s)
548 	{
549 		enforce!ConvException(s.endsWith('"'));
550 
551 		if (s.startsWith(`W/"`)) {
552 			ETag ret = { weak: true, tag: s[3 .. $ - 1] };
553 			return ret;
554 		} else if (s.startsWith('"')) {
555 			ETag ret;
556 			ret.tag = s[1 .. $ - 1];
557 			return ret;
558 		} else {
559 			throw new ConvException(`ETag didn't start with W/" nor with " !`);
560 		}
561 	}
562 
563 	string toString() const @property
564 	{
565 		return text(weak ? `W/"` : `"`, tag, '"');
566 	}
567 
568 	/**
569 		Encodes the bytes with URL Base64 to a human readable string and returns an ETag struct wrapping it.
570 	 */
571 	static ETag fromBytesBase64URLNoPadding(scope const(ubyte)[] bytes, Flag!"weak" weak = No.weak)
572 	{
573 		import std.base64 : Base64URLNoPadding;
574 
575 		return ETag(weak, Base64URLNoPadding.encode(bytes).idup);
576 	}
577 
578 	/**
579 		Hashes the input bytes with md5 and returns an URL Base64 encoded representation as ETag.
580 	 */
581 	static ETag md5(T...)(Flag!"weak" weak, T data)
582 	{
583 		import std.digest.md : md5Of;
584 
585 		return fromBytesBase64URLNoPadding(md5Of(data), weak);
586 	}
587 }
588 
589 /**
590 	Matches a given match expression with a specific ETag. Can allow or disallow weak ETags and supports multiple tags.
591 
592 	Standards: https://tools.ietf.org/html/rfc7232#section-2.3.2
593 */
594 bool cacheMatch(string match, ETag etag, Flag!"allowWeak" allow_weak)
595 {
596 	if (match == "*") {
597 		return true;
598 	}
599 
600 	if ((etag.weak && !allow_weak) || !match.length) {
601 		return false;
602 	}
603 
604 	auto allBytes = match.representation;
605 	auto range = allBytes;
606 
607 	while (!range.empty)
608 	{
609 		range = range.stripLeft!isWhite;
610 		bool isWeak = range.skipOver("W/");
611 		if (!range.skipOver('"'))
612 			return false; // malformed
613 
614 		auto end = range.countUntil('"');
615 		if (end == -1)
616 			return false; // malformed
617 
618 		const check = range[0 .. end];
619 		range = range[end .. $];
620 
621 		if (allow_weak || !isWeak) {
622 			if (check == etag.tag) {
623 				return true;
624 			}
625 		}
626 
627 		range.skipOver('"');
628 		range = range.stripLeft!isWhite;
629 
630 		if (!range.skipOver(","))
631 			return false; // malformed
632 	}
633 
634 	return false;
635 }
636 
637 unittest
638 {
639 	// from RFC 7232 Section 2.3.2
640 	// +--------+--------+-------------------+-----------------+
641 	// | ETag 1 | ETag 2 | Strong Comparison | Weak Comparison |
642 	// +--------+--------+-------------------+-----------------+
643 	// | W/"1"  | W/"1"  | no match          | match           |
644 	// | W/"1"  | W/"2"  | no match          | no match        |
645 	// | W/"1"  | "1"    | no match          | match           |
646 	// | "1"    | "1"    | match             | match           |
647 	// +--------+--------+-------------------+-----------------+
648 
649 	assert(!cacheMatch(`W/"1"`, ETag(Yes.weak, "1"), No.allowWeak));
650 	assert( cacheMatch(`W/"1"`, ETag(Yes.weak, "1"), Yes.allowWeak));
651 
652 	assert(!cacheMatch(`W/"1"`, ETag(Yes.weak, "2"), No.allowWeak));
653 	assert(!cacheMatch(`W/"1"`, ETag(Yes.weak, "2"), Yes.allowWeak));
654 
655 	assert(!cacheMatch(`W/"1"`, ETag(No.weak, "1"), No.allowWeak));
656 	assert( cacheMatch(`W/"1"`, ETag(No.weak, "1"), Yes.allowWeak));
657 
658 	assert(cacheMatch(`"1"`, ETag(No.weak, "1"), No.allowWeak));
659 	assert(cacheMatch(`"1"`, ETag(No.weak, "1"), Yes.allowWeak));
660 
661 	assert(cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "xyzzy"), No.allowWeak));
662 	assert(cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "xyzzy"), Yes.allowWeak));
663 
664 	assert(!cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "xyzzz"), No.allowWeak));
665 	assert(!cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "xyzzz"), Yes.allowWeak));
666 
667 	assert(cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "r2d2xxxx"), No.allowWeak));
668 	assert(cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "r2d2xxxx"), Yes.allowWeak));
669 
670 	assert(cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "c3piozzzz"), No.allowWeak));
671 	assert(cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "c3piozzzz"), Yes.allowWeak));
672 
673 	assert(!cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, ""), No.allowWeak));
674 	assert(!cacheMatch(`"xyzzy","r2d2xxxx", "c3piozzzz"`, ETag(No.weak, ""), Yes.allowWeak));
675 
676 	assert(!cacheMatch(`"xyzzy",W/"r2d2xxxx", "c3piozzzz"`, ETag(Yes.weak, "r2d2xxxx"), No.allowWeak));
677 	assert( cacheMatch(`"xyzzy",W/"r2d2xxxx", "c3piozzzz"`, ETag(Yes.weak, "r2d2xxxx"), Yes.allowWeak));
678 	assert(!cacheMatch(`"xyzzy",W/"r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "r2d2xxxx"), No.allowWeak));
679 	assert( cacheMatch(`"xyzzy",W/"r2d2xxxx", "c3piozzzz"`, ETag(No.weak, "r2d2xxxx"), Yes.allowWeak));
680 }
681 
682 private RangeSpec parseRangeHeader(string range_spec, ulong file_size, scope HTTPServerResponse res)
683 {
684 	RangeSpec ret;
685 
686 	auto range = range_spec.chompPrefix("bytes=");
687 	if (range.canFind(','))
688 		throw new HTTPStatusException(HTTPStatus.notImplemented);
689 	auto s = range.split("-");
690 	if (s.length != 2)
691 		throw new HTTPStatusException(HTTPStatus.badRequest);
692 
693 	// https://tools.ietf.org/html/rfc7233
694 	// Range can be in form "-\d", "\d-" or "\d-\d"
695 	try {
696 		if (s[0].length) {
697 			ret.min = s[0].to!ulong;
698 			ret.max = s[1].length ? s[1].to!ulong + 1 : file_size;
699 		} else if (s[1].length) {
700 			ret.min = file_size - min(s[1].to!ulong, file_size);
701 			ret.max = file_size;
702 		} else {
703 			throw new HTTPStatusException(HTTPStatus.badRequest);
704 		}
705 	} catch (ConvException) {
706 		throw new HTTPStatusException(HTTPStatus.badRequest);
707 	}
708 
709 	if (ret.max > file_size) ret.max = file_size;
710 
711 	if (ret.min >= ret.max) {
712 		res.headers["Content-Range"] = "bytes */%s".format(file_size);
713 		throw new HTTPStatusException(HTTPStatus.rangeNotSatisfiable);
714 	}
715 
716 	return ret;
717 }
718 
719 unittest {
720 	auto res = createTestHTTPServerResponse();
721 	assertThrown(parseRangeHeader("bytes=2-1", 10, res));
722 	assertThrown(parseRangeHeader("bytes=10-10", 10, res));
723 	assertThrown(parseRangeHeader("bytes=0-0", 0, res));
724 	assert(parseRangeHeader("bytes=10-20", 100, res) == RangeSpec(10, 21));
725 	assert(parseRangeHeader("bytes=0-0", 1, res) == RangeSpec(0, 1));
726 	assert(parseRangeHeader("bytes=0-20", 2, res) == RangeSpec(0, 2));
727 	assert(parseRangeHeader("bytes=1-20", 2, res) == RangeSpec(1, 2));
728 }
729 
730 private struct RangeSpec {
731 	ulong min, max;
732 }