diff --git a/lib/middleware/static.js b/lib/middleware/static.js index 9b10f43..3422bbc 100644 --- a/lib/middleware/static.js +++ b/lib/middleware/static.js @@ -13,11 +13,21 @@ * * `options`: an object with fine-grained configuration options * ** `servePrecompressed`: if true (default), static resources with a pre-compressed gzip equivalent will be * served instead of the original file. + * ** `maxAge`: set the `Cache-Control` header in seconds, default is 0. + * ** `lastModified`: if true (default), set the `Last-Modified` header to the modification date of the resource. + * ** `setHeaders`: allows a user to specify a function which returns additional headers to be set for a given path. + * The given function produces an object containing the header names as keys and takes the path as + * argument. User-provided headers take precedence over all other headers. + * ** `dotfiles`: determines how should files starting with a dot character be treated. + * `allow` (default) serves dotfiles, `deny` respond with status 403, `ignore` respond with status 404. * * You can call `static()` multiple times to register multiple resources to be served. */ +const RFC_822 = "EEE, dd MMM yyyy HH:mm:ss z"; + var objects = require("ringo/utils/objects"); +var dates = require("ringo/utils/dates"); var response = require("ringo/jsgi/response"); var {mimeType} = require("ringo/mime"); @@ -34,7 +44,11 @@ exports.middleware = function static(next, app) { app.static = function(base, index, baseURI, options) { var opts = objects.merge(options || {}, { - "servePrecompressed": true + "servePrecompressed": true, + "dotfiles": "allow", + "lastModified": true, + "maxAge": 0, + "setHeaders": null }); var baseRepository; if (typeof base === "string") { @@ -63,18 +77,39 @@ exports.middleware = function static(next, app) { if (path.length > 1) { var resource = config.repository.getResource(path); if (resource && resource.exists()) { + if (resource.getName().charAt(0) === ".") { + switch (config.options.dotfiles.toLowerCase()) { + case "deny": return response.text("403 Forbidden").setStatus(403); + case "ignore": return response.text("404 Not Found").setStatus(404); + } + } + + let userHeaders = typeof config.options.setHeaders === "function" ? config.options.setHeaders() : {}; + + let defaultHeaders = { + "Cache-Control": "max-age=" + config.options.maxAge || 0 + }; + + if (config.options.lastModified === true) { + defaultHeaders["Last-Modified"] = dates.format(new Date(resource.lastModified()), RFC_822, "en", "GMT"); + } + // check if precompressed gzip resource is available and it's serving is enabled let acceptEncoding = (request.headers["accept-encoding"] || "").toLowerCase(); if (acceptEncoding.indexOf("gzip") > -1 && config.options.servePrecompressed === true) { let gzippedResource = config.repository.getResource(path + ".gz"); if (gzippedResource && gzippedResource.exists()) { let jsgiResponse = response.static(gzippedResource, mimeType(path, "text/plain")); - jsgiResponse.headers["Content-Encoding"] = "gzip"; + jsgiResponse.headers = objects.merge(userHeaders, jsgiResponse.headers, { + "Content-Encoding": "gzip" + }, defaultHeaders); return jsgiResponse; } } - return response.static(resource, mimeType(path, "text/plain")); + let jsgiResponse = response.static(resource, mimeType(path, "text/plain")); + jsgiResponse.headers = objects.merge(userHeaders, jsgiResponse.headers, defaultHeaders); + return jsgiResponse; } } } diff --git a/test/middleware/fixtures/.DotfileExample b/test/middleware/fixtures/.DotfileExample new file mode 100644 index 0000000..1f5b106 --- /dev/null +++ b/test/middleware/fixtures/.DotfileExample @@ -0,0 +1 @@ +This is a dotfile \ No newline at end of file diff --git a/test/middleware/static_test.js b/test/middleware/static_test.js index 969880d..bac514d 100644 --- a/test/middleware/static_test.js +++ b/test/middleware/static_test.js @@ -1,3 +1,4 @@ +const fs = require("fs"); const system = require("system"); const assert = require("assert"); @@ -58,10 +59,174 @@ exports.testStaticFile = function() { }); assert.equal(response.headers["Content-Type"], "text/html"); assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.equal(response.headers["Cache-Control"], "max-age=0"); }; -exports.testPrecompressedStaticFile = function() { +exports.testMaxAge = function() { + let app = new Application(); + + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic", { + maxAge: 1000 + }); + + let response = app({ + method: 'GET', + headers: {}, + env: {}, + pathInfo: '/customStatic/' + }); + assert.equal(response.headers["Content-Type"], "text/html"); + assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.equal(response.headers["Cache-Control"], "max-age=1000"); + + app = new Application(); + + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic"); + + response = app({ + method: 'GET', + headers: {}, + env: {}, + pathInfo: '/customStatic/' + }); + assert.equal(response.headers["Content-Type"], "text/html"); + assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.equal(response.headers["Cache-Control"], "max-age=0"); +}; + +exports.testLastModified = function() { var app = new Application(); + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic", { + lastModified: false + }); + + let response = app({ + method: 'GET', + headers: {}, + env: {}, + pathInfo: '/customStatic/' + }); + assert.equal(response.headers["Content-Type"], "text/html"); + assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.isUndefined(response.headers["Last-Modified"]); + + app = new Application(); + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic"); + + response = app({ + method: 'GET', + headers: {}, + env: {}, + pathInfo: '/customStatic/' + }); + assert.equal(response.headers["Content-Type"], "text/html"); + assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.isNotUndefined(response.headers["Last-Modified"]); + + let lastModified = fs.lastModified(fs.join(module.resolve("./fixtures"), "index.html")); + let sdf = new java.text.SimpleDateFormat("E, d MMM yyyy HH:mm:ss z", java.util.Locale.US); + sdf.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); + assert.equal(response.headers["Last-Modified"], sdf.format(lastModified)); +}; + +exports.testSetHeaders = function() { + let app = new Application(); + + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic", { + setHeaders: function() { + return { + "x-foo": "bar", + "x-seestadt-cafe": "united in cycling", + "Cache-Control": "max-age=123456" + } + } + }); + + let response = app({ + method: 'GET', + headers: { + "accept-encoding": "gzip" + }, + env: {}, + pathInfo: '/customStatic/' + }); + + assert.equal(response.status, 200); + assert.equal(response.headers["Content-Type"], "text/html"); + assert.equal(response.headers["Content-Encoding"], "gzip"); + assert.equal(response.headers["Cache-Control"], "max-age=123456"); + assert.equal(response.headers["x-foo"], "bar"); + assert.equal(response.headers["x-seestadt-cafe"], "united in cycling"); +}; + +exports.testDotfiles = function() { + let app = new Application(); + + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic", { + "dotfiles": "allow" + }); + + let response = app({ + method: 'GET', + headers: { + "accept-encoding": "gzip" + }, + env: {}, + pathInfo: '/customStatic/.DotfileExample' + }); + + assert.equal(response.status, 200); + assert.equal(response.headers["Content-Type"], "text/plain"); + + // DENY + app = new Application(); + + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic", { + "dotfiles": "deny" + }); + + response = app({ + method: 'GET', + headers: { + "accept-encoding": "gzip" + }, + env: {}, + pathInfo: '/customStatic/.DotfileExample' + }); + + assert.equal(response.status, 403); + assert.equal(response.headers["content-type"], "text/plain; charset=utf-8"); + + // IGNORE + app = new Application(); + + app.configure("static"); + app.static(module.resolve("./fixtures"), "index.html", "/customStatic", { + "dotfiles": "IgNoRe" + }); + + response = app({ + method: 'GET', + headers: { + "accept-encoding": "gzip" + }, + env: {}, + pathInfo: '/customStatic/.DotfileExample' + }); + + assert.equal(response.status, 404); + assert.equal(response.headers["content-type"], "text/plain; charset=utf-8"); +}; + +exports.testPrecompressedStaticFile = function() { + let app = new Application(); app.configure("static"); app.static(module.resolve("./fixtures"), "index.html", "/customStatic", {}); @@ -78,6 +243,7 @@ exports.testPrecompressedStaticFile = function() { assert.equal(response.status, 200); assert.equal(response.headers["Content-Type"], "text/html"); assert.equal(response.headers["Content-Encoding"], "gzip"); + assert.equal(response.headers["Cache-Control"], "max-age=0"); response = app({ method: 'GET', @@ -91,6 +257,7 @@ exports.testPrecompressedStaticFile = function() { assert.equal(response.status, 200); assert.equal(response.headers["Content-Type"], "text/html"); assert.equal(response.headers["Content-Encoding"], "gzip"); + assert.equal(response.headers["Cache-Control"], "max-age=0"); const ba = new ByteArray([0x1F, 0x8B, 0x08, 0x08, 0x81, 0xF1, 0xD6, 0x56, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xB3, 0x51, 0x74, 0xF1, 0x77, 0x0E, 0x89, 0x0C, 0x70, @@ -105,6 +272,7 @@ exports.testPrecompressedStaticFile = function() { }); assert.equal(response.headers["Content-Type"], "text/html"); assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.equal(response.headers["Cache-Control"], "max-age=0"); }; exports.testDeactivatedPrecompression = function() { @@ -123,6 +291,7 @@ exports.testDeactivatedPrecompression = function() { }); assert.equal(response.headers["Content-Type"], "text/html"); assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.equal(response.headers["Cache-Control"], "max-age=0"); response = app({ method: 'GET', @@ -132,6 +301,7 @@ exports.testDeactivatedPrecompression = function() { }); assert.equal(response.headers["Content-Type"], "text/html"); assert.equal(bodyAsString(response.body, "utf-8"), ""); + assert.equal(response.headers["Cache-Control"], "max-age=0"); }; if (require.main === module) {