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 }