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 }