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 }