nodulator
Version:
Complete NodeJS Framework for Restfull APIs
404 lines (331 loc) • 11.3 kB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/*jslint node: true, nomen: true, white: true*/
;
var crypto = require('crypto'),
format = require('./compat').format,
fs = require('fs'),
path = require('path'),
_ = require('underscore'),
url = require('url');
var HASH_LENGTH = 10;
var existsSync = fs.existsSync || path.existsSync,
_opts,
_assets,
_cache = {};
exports.setup = function (assets, options) {
_assets = assets;
_opts = clean_options(options);
var prefix = escape_regex(no_url(_opts.prefix)),
fmt_regexp = new RegExp(format('^\\/%s[a-f0-9]{%s}', prefix, HASH_LENGTH));
var is_cachified_uri = function (uri) {
var m = uri.match(fmt_regexp);
return m && m.index === 0;
};
var remove_prefix = function(url) {
return url.replace(fmt_regexp, '');
};
var get_true_path = function (uri) {
var _url = remove_prefix(uri);
return _opts.url_to_paths[_url] || path.join(_opts.root, _url);
};
var get_expected_hash = function(url) {
// the +1's take care of the slashes.
var start_char = prefix.length + 1;
var end_char = start_char + HASH_LENGTH;
return url.slice(start_char, end_char);
};
return function (req, resp, next) {
// If the first path element is a md5 hash AND one of the following:
// * the path is _opts.prefix
// * the filename is a key in our _opts[family]
// * or the file exists on the filesystem
set_locals(resp);
var uri = get_uri(req.url);
if (! is_cachified_uri(uri)) {
return next();
}
var true_path = get_true_path(uri);
if(! check_exists(assets, true_path, uri)) {
return next();
}
// determine actual current hash of the file, it's worth the disk
// read to ensure we never serve bogus resources and poison caches
// issue #31
var expected_hash = get_expected_hash(uri);
does_file_match_expected_hash(true_path, expected_hash, function (is_good_hash) {
if (! is_good_hash) {
// invalid hash, let the 404 handler take control
return next();
}
req.url = remove_prefix(req.url);
set_cache_headers(resp);
// let the static middleware handle the rest
next();
});
};
};
var get_uri = function (_url) {
var req_url = url.parse(_url);
return req_url.pathname || '';
};
var check_exists = function(assets, true_path, uri) {
// custom prefix with non-file assets.
if (_opts.prefix !== '' && uri.indexOf('/' + _opts.prefix) === 0) {
return true;
} else if (assets[true_path]) {
return true;
} else if (_cache[true_path]) {
return _cache[true_path].exists;
} else if (existsSync(true_path)) {
// Would we ever want to use cachify for non-file resources?
_cache[true_path] = {exists: true};
return true;
} else {
// Hmm potential DOS Attack here... maybe we shouldn't cache
// checking disk every time isn't that bad - just like static
// middleware
// Or use an LRU?
console.warn('Cachify like URL, but no file found');
_cache[true_path] = {exists: false};
}
return false;
};
var set_cache_headers = function (resp) {
resp.setHeader('Cache-Control', 'public, max-age=31536000');
if (_opts.control_headers === true) {
resp.on('header', function () {
remove_other_cache_headers(resp);
});
}
};
var remove_other_cache_headers = function (resp) {
// Relax other middleware... I got this one
['ETag', 'Last-Modified'].forEach(function (header) {
if (resp.getHeader(header)) resp.removeHeader(header);
if (resp.getHeader(header)) resp.removeHeader(header);
});
};
var get_hash_for_contents = function (contents) {
var md5 = crypto.createHash('md5');
if (contents) md5.update(contents);
return md5.digest('hex').slice(0, HASH_LENGTH);
};
// check whether the MD5 of the contents matches the expected hash.
var do_contents_match_expected_hash = function (contents, expected_hash) {
var actual_hash = get_hash_for_contents(contents);
return expected_hash === actual_hash;
};
var does_file_match_expected_hash = function (true_path, expected_hash, done) {
// determine actual current hash of the file, it's worth the disk
// read to ensure we never serve bogus resources and poison caches
// issue #31
fs.readFile(true_path, function (err, contents) {
// ignore error
done(do_contents_match_expected_hash(contents, expected_hash));
});
};
// clean the input options passed in by the user.
var clean_options = function (options) {
options = options || {};
var cleaned = options;
if (options.production === undefined) {
cleaned.production = true;
}
if (! options.root) {
cleaned.root = '.';
}
if (! options.url_to_paths) {
cleaned.url_to_paths = {};
}
if (! options.prefix) {
cleaned.prefix = '';
// Must end with a '/'
} else if (options.prefix.slice(-1) !== '/') {
cleaned.prefix += '/';
}
// Don't lead with a '/'
if (cleaned.prefix.charAt(0) === '/') {
cleaned.prefix = cleaned.prefix.slice(1);
}
if (options.control_headers === undefined) {
cleaned.control_headers = false;
}
if (options.debug === undefined) {
cleaned.debug = false;
}
return cleaned;
};
/**
* If filename is an absolute path (starts with a '/') The hash
* will be pre-pended with be slashified.
* Examples:
* /foo.js -> /a998d8e98f/foo.js
* foo.js -> a998d8e98f/foo.js
* http://example.com/foo.js -> http://example.com/foo.js
*/
var should_skip_hashification = function (resource) {
if (_opts.production !== true && _opts.debug === false) {
return true;
}
// full URL, bail immediately.
if (resource.indexOf('://') > -1) {
return true;
}
};
var get_predefined_hash = function (hash) {
return hash || _opts.global_hash;
};
var url_to_filename = function (url) {
// fragment identifiers on URLs are never sent to the server and they
// should not exist on the filename. When looking up the resource on
// disk, do so without the fragment identifier.
var no_hash_url = url.replace(/#.*$/, '');
return _opts.url_to_paths[no_hash_url] || path.join(_opts.root, no_hash_url);
};
var get_resource_hash = function(resource) {
var filename = url_to_filename(resource);
if (_cache[filename] && _cache[filename].hash) {
return _cache[filename].hash;
//console.info('cache hit ', filename);
} else {
//console.info('cache miss ', filename);
try {
var data = fs.readFileSync(filename);
// Expensive, maintain in-memory cache
if (! _cache[filename]) _cache[filename] = {exists: true};
return _cache[filename].hash = get_hash_for_contents(data);
} catch (e) {
// Not intersting to cache, programmer error?
exports.uncached_resources.push(resource);
console.error('Cachify bailing on hash... no such file ' + filename);
console.error('Options: %s', JSON.stringify(_opts));
console.error(e);
}
}
};
var generate_hashed_url = function (resource, hash) {
if (_opts.prefix.indexOf('://') === -1 && resource[0] === '/') {
return format('/%s%s%s', _opts.prefix, hash, resource);
}
if (resource[0] !== '/') {
resource = '/' + resource;
}
return format('%s%s%s', _opts.prefix, hash, resource);
};
var hashify = function (resource, hash) {
if (typeof resource !== 'string')
throw "cachify ERROR, expected string for resource, got " + resource;
if (should_skip_hashification(resource)) {
return resource;
}
hash = get_predefined_hash(hash) || get_resource_hash(resource);
if (! hash) {
return resource;
}
return generate_hashed_url(resource, hash);
};
var should_use_development_resources = function (uri) {
var uri_assets = _assets[uri];
return _opts.production !== true && uri_assets;
};
var generate_tags_for_resources = function (resources, link_fmt, query, hash) {
// generate one tag per resource, then combine them into one string.
return _.map(resources, function (f) {
var asset_url = url.parse(f);
var asset_uri = format('%s%s%s',
asset_url.pathname || '',
query ? '?' + query : '',
asset_url.hash || ''
);
return format(link_fmt, hashify(asset_uri, hash));
}).join('\n');
};
var generate_tags_for_filename = function (filename, link_fmt, hash) {
var req_url = url.parse(filename),
uri = req_url.pathname || '';
var resources;
if (should_use_development_resources(uri)) {
resources = _assets[uri];
} else {
resources = [filename];
}
return generate_tags_for_resources(resources, link_fmt, req_url.query, hash);
};
var cachify_js = exports.cachify_js = function (filename, options) {
if (! options) options = {};
var link_fmt = '<script src="%s"';
/**
* indicate to a browser that the script is meant to be executed after the
* document has been parsed.
*/
if (options.defer && _opts.production === true) {
link_fmt += ' defer';
}
/**
* indicate that the browser should, if possible, execute the script
* asynchronously
*/
if (options.async && _opts.production === true) {
link_fmt += ' async';
}
link_fmt += '></script>';
return generate_tags_for_filename(filename, link_fmt, options.hash);
};
var cachify_css = exports.cachify_css = function (filename, options) {
if (! options) options = {};
return generate_tags_for_filename(filename,
'<link href="%s" rel="stylesheet" type="text/css">',
options.hash);
};
var cachify_prefetch = exports.cachify_prefetch = function (filename, options) {
if (! options) options = {};
return generate_tags_for_filename(filename,
'<link rel="prefetch" href="%s">',
options.hash);
};
var cachify = exports.cachify = function (filename, options) {
var tag_format;
if (! options) options = {};
if (options.tag_format)
tag_format = options.tag_format;
else
tag_format = '%s';
return generate_tags_for_filename(filename, tag_format, options.hash);
};
var no_url = function (prefix) {
if (prefix.indexOf('://') === -1) return prefix;
var m = prefix.match(/^[a-z]{3,5}:\/\/[a-z0-9\-_.]*(?:\:[0-9]*)?\/(.*)$/i);
if (m) return m[1];
else return prefix;
};
var escape_regex = function (str) {
return str.replace('/', '\/');
};
var set_locals = function (resp) {
// express 2
if (typeof resp.local === 'function') {
resp.local('cachify_prefetch', cachify_prefetch);
resp.local('cachify_js', cachify_js);
resp.local('cachify_css', cachify_css);
resp.local('cachify', cachify);
}
// express 3
else if (typeof resp.locals === 'function') {
resp.locals({
cachify_prefetch: cachify_prefetch,
cachify_js: cachify_js,
cachify_css: cachify_css,
cachify: cachify
});
}
// express 4
else if (typeof resp.locals === 'object') {
resp.locals.cachify_prefetch = cachify_prefetch;
resp.locals.cachify_js = cachify_js;
resp.locals.cachify_css = cachify_css;
resp.locals.cachify = cachify;
}
};
exports.uncached_resources = [];