You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1130 lines
23 KiB
1130 lines
23 KiB
/*! |
|
* send |
|
* Copyright(c) 2012 TJ Holowaychuk |
|
* Copyright(c) 2014-2016 Douglas Christopher Wilson |
|
* MIT Licensed |
|
*/ |
|
|
|
'use strict' |
|
|
|
/** |
|
* Module dependencies. |
|
* @private |
|
*/ |
|
|
|
var createError = require('http-errors') |
|
var debug = require('debug')('send') |
|
var deprecate = require('depd')('send') |
|
var destroy = require('destroy') |
|
var encodeUrl = require('encodeurl') |
|
var escapeHtml = require('escape-html') |
|
var etag = require('etag') |
|
var fresh = require('fresh') |
|
var fs = require('fs') |
|
var mime = require('mime') |
|
var ms = require('ms') |
|
var onFinished = require('on-finished') |
|
var parseRange = require('range-parser') |
|
var path = require('path') |
|
var statuses = require('statuses') |
|
var Stream = require('stream') |
|
var util = require('util') |
|
|
|
/** |
|
* Path function references. |
|
* @private |
|
*/ |
|
|
|
var extname = path.extname |
|
var join = path.join |
|
var normalize = path.normalize |
|
var resolve = path.resolve |
|
var sep = path.sep |
|
|
|
/** |
|
* Regular expression for identifying a bytes Range header. |
|
* @private |
|
*/ |
|
|
|
var BYTES_RANGE_REGEXP = /^ *bytes=/ |
|
|
|
/** |
|
* Maximum value allowed for the max age. |
|
* @private |
|
*/ |
|
|
|
var MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year |
|
|
|
/** |
|
* Regular expression to match a path with a directory up component. |
|
* @private |
|
*/ |
|
|
|
var UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ |
|
|
|
/** |
|
* Module exports. |
|
* @public |
|
*/ |
|
|
|
module.exports = send |
|
module.exports.mime = mime |
|
|
|
/** |
|
* Return a `SendStream` for `req` and `path`. |
|
* |
|
* @param {object} req |
|
* @param {string} path |
|
* @param {object} [options] |
|
* @return {SendStream} |
|
* @public |
|
*/ |
|
|
|
function send (req, path, options) { |
|
return new SendStream(req, path, options) |
|
} |
|
|
|
/** |
|
* Initialize a `SendStream` with the given `path`. |
|
* |
|
* @param {Request} req |
|
* @param {String} path |
|
* @param {object} [options] |
|
* @private |
|
*/ |
|
|
|
function SendStream (req, path, options) { |
|
Stream.call(this) |
|
|
|
var opts = options || {} |
|
|
|
this.options = opts |
|
this.path = path |
|
this.req = req |
|
|
|
this._acceptRanges = opts.acceptRanges !== undefined |
|
? Boolean(opts.acceptRanges) |
|
: true |
|
|
|
this._cacheControl = opts.cacheControl !== undefined |
|
? Boolean(opts.cacheControl) |
|
: true |
|
|
|
this._etag = opts.etag !== undefined |
|
? Boolean(opts.etag) |
|
: true |
|
|
|
this._dotfiles = opts.dotfiles !== undefined |
|
? opts.dotfiles |
|
: 'ignore' |
|
|
|
if (this._dotfiles !== 'ignore' && this._dotfiles !== 'allow' && this._dotfiles !== 'deny') { |
|
throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') |
|
} |
|
|
|
this._hidden = Boolean(opts.hidden) |
|
|
|
if (opts.hidden !== undefined) { |
|
deprecate('hidden: use dotfiles: \'' + (this._hidden ? 'allow' : 'ignore') + '\' instead') |
|
} |
|
|
|
// legacy support |
|
if (opts.dotfiles === undefined) { |
|
this._dotfiles = undefined |
|
} |
|
|
|
this._extensions = opts.extensions !== undefined |
|
? normalizeList(opts.extensions, 'extensions option') |
|
: [] |
|
|
|
this._immutable = opts.immutable !== undefined |
|
? Boolean(opts.immutable) |
|
: false |
|
|
|
this._index = opts.index !== undefined |
|
? normalizeList(opts.index, 'index option') |
|
: ['index.html'] |
|
|
|
this._lastModified = opts.lastModified !== undefined |
|
? Boolean(opts.lastModified) |
|
: true |
|
|
|
this._maxage = opts.maxAge || opts.maxage |
|
this._maxage = typeof this._maxage === 'string' |
|
? ms(this._maxage) |
|
: Number(this._maxage) |
|
this._maxage = !isNaN(this._maxage) |
|
? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) |
|
: 0 |
|
|
|
this._root = opts.root |
|
? resolve(opts.root) |
|
: null |
|
|
|
if (!this._root && opts.from) { |
|
this.from(opts.from) |
|
} |
|
} |
|
|
|
/** |
|
* Inherits from `Stream`. |
|
*/ |
|
|
|
util.inherits(SendStream, Stream) |
|
|
|
/** |
|
* Enable or disable etag generation. |
|
* |
|
* @param {Boolean} val |
|
* @return {SendStream} |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.etag = deprecate.function(function etag (val) { |
|
this._etag = Boolean(val) |
|
debug('etag %s', this._etag) |
|
return this |
|
}, 'send.etag: pass etag as option') |
|
|
|
/** |
|
* Enable or disable "hidden" (dot) files. |
|
* |
|
* @param {Boolean} path |
|
* @return {SendStream} |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.hidden = deprecate.function(function hidden (val) { |
|
this._hidden = Boolean(val) |
|
this._dotfiles = undefined |
|
debug('hidden %s', this._hidden) |
|
return this |
|
}, 'send.hidden: use dotfiles option') |
|
|
|
/** |
|
* Set index `paths`, set to a falsy |
|
* value to disable index support. |
|
* |
|
* @param {String|Boolean|Array} paths |
|
* @return {SendStream} |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.index = deprecate.function(function index (paths) { |
|
var index = !paths ? [] : normalizeList(paths, 'paths argument') |
|
debug('index %o', paths) |
|
this._index = index |
|
return this |
|
}, 'send.index: pass index as option') |
|
|
|
/** |
|
* Set root `path`. |
|
* |
|
* @param {String} path |
|
* @return {SendStream} |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.root = function root (path) { |
|
this._root = resolve(String(path)) |
|
debug('root %s', this._root) |
|
return this |
|
} |
|
|
|
SendStream.prototype.from = deprecate.function(SendStream.prototype.root, |
|
'send.from: pass root as option') |
|
|
|
SendStream.prototype.root = deprecate.function(SendStream.prototype.root, |
|
'send.root: pass root as option') |
|
|
|
/** |
|
* Set max-age to `maxAge`. |
|
* |
|
* @param {Number} maxAge |
|
* @return {SendStream} |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.maxage = deprecate.function(function maxage (maxAge) { |
|
this._maxage = typeof maxAge === 'string' |
|
? ms(maxAge) |
|
: Number(maxAge) |
|
this._maxage = !isNaN(this._maxage) |
|
? Math.min(Math.max(0, this._maxage), MAX_MAXAGE) |
|
: 0 |
|
debug('max-age %d', this._maxage) |
|
return this |
|
}, 'send.maxage: pass maxAge as option') |
|
|
|
/** |
|
* Emit error with `status`. |
|
* |
|
* @param {number} status |
|
* @param {Error} [err] |
|
* @private |
|
*/ |
|
|
|
SendStream.prototype.error = function error (status, err) { |
|
// emit if listeners instead of responding |
|
if (hasListeners(this, 'error')) { |
|
return this.emit('error', createError(status, err, { |
|
expose: false |
|
})) |
|
} |
|
|
|
var res = this.res |
|
var msg = statuses[status] || String(status) |
|
var doc = createHtmlDocument('Error', escapeHtml(msg)) |
|
|
|
// clear existing headers |
|
clearHeaders(res) |
|
|
|
// add error headers |
|
if (err && err.headers) { |
|
setHeaders(res, err.headers) |
|
} |
|
|
|
// send basic response |
|
res.statusCode = status |
|
res.setHeader('Content-Type', 'text/html; charset=UTF-8') |
|
res.setHeader('Content-Length', Buffer.byteLength(doc)) |
|
res.setHeader('Content-Security-Policy', "default-src 'self'") |
|
res.setHeader('X-Content-Type-Options', 'nosniff') |
|
res.end(doc) |
|
} |
|
|
|
/** |
|
* Check if the pathname ends with "/". |
|
* |
|
* @return {boolean} |
|
* @private |
|
*/ |
|
|
|
SendStream.prototype.hasTrailingSlash = function hasTrailingSlash () { |
|
return this.path[this.path.length - 1] === '/' |
|
} |
|
|
|
/** |
|
* Check if this is a conditional GET request. |
|
* |
|
* @return {Boolean} |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.isConditionalGET = function isConditionalGET () { |
|
return this.req.headers['if-match'] || |
|
this.req.headers['if-unmodified-since'] || |
|
this.req.headers['if-none-match'] || |
|
this.req.headers['if-modified-since'] |
|
} |
|
|
|
/** |
|
* Check if the request preconditions failed. |
|
* |
|
* @return {boolean} |
|
* @private |
|
*/ |
|
|
|
SendStream.prototype.isPreconditionFailure = function isPreconditionFailure () { |
|
var req = this.req |
|
var res = this.res |
|
|
|
// if-match |
|
var match = req.headers['if-match'] |
|
if (match) { |
|
var etag = res.getHeader('ETag') |
|
return !etag || (match !== '*' && parseTokenList(match).every(function (match) { |
|
return match !== etag && match !== 'W/' + etag && 'W/' + match !== etag |
|
})) |
|
} |
|
|
|
// if-unmodified-since |
|
var unmodifiedSince = parseHttpDate(req.headers['if-unmodified-since']) |
|
if (!isNaN(unmodifiedSince)) { |
|
var lastModified = parseHttpDate(res.getHeader('Last-Modified')) |
|
return isNaN(lastModified) || lastModified > unmodifiedSince |
|
} |
|
|
|
return false |
|
} |
|
|
|
/** |
|
* Strip content-* header fields. |
|
* |
|
* @private |
|
*/ |
|
|
|
SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields () { |
|
var res = this.res |
|
var headers = getHeaderNames(res) |
|
|
|
for (var i = 0; i < headers.length; i++) { |
|
var header = headers[i] |
|
if (header.substr(0, 8) === 'content-' && header !== 'content-location') { |
|
res.removeHeader(header) |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Respond with 304 not modified. |
|
* |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.notModified = function notModified () { |
|
var res = this.res |
|
debug('not modified') |
|
this.removeContentHeaderFields() |
|
res.statusCode = 304 |
|
res.end() |
|
} |
|
|
|
/** |
|
* Raise error that headers already sent. |
|
* |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.headersAlreadySent = function headersAlreadySent () { |
|
var err = new Error('Can\'t set headers after they are sent.') |
|
debug('headers already sent') |
|
this.error(500, err) |
|
} |
|
|
|
/** |
|
* Check if the request is cacheable, aka |
|
* responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}). |
|
* |
|
* @return {Boolean} |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.isCachable = function isCachable () { |
|
var statusCode = this.res.statusCode |
|
return (statusCode >= 200 && statusCode < 300) || |
|
statusCode === 304 |
|
} |
|
|
|
/** |
|
* Handle stat() error. |
|
* |
|
* @param {Error} error |
|
* @private |
|
*/ |
|
|
|
SendStream.prototype.onStatError = function onStatError (error) { |
|
switch (error.code) { |
|
case 'ENAMETOOLONG': |
|
case 'ENOENT': |
|
case 'ENOTDIR': |
|
this.error(404, error) |
|
break |
|
default: |
|
this.error(500, error) |
|
break |
|
} |
|
} |
|
|
|
/** |
|
* Check if the cache is fresh. |
|
* |
|
* @return {Boolean} |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.isFresh = function isFresh () { |
|
return fresh(this.req.headers, { |
|
'etag': this.res.getHeader('ETag'), |
|
'last-modified': this.res.getHeader('Last-Modified') |
|
}) |
|
} |
|
|
|
/** |
|
* Check if the range is fresh. |
|
* |
|
* @return {Boolean} |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.isRangeFresh = function isRangeFresh () { |
|
var ifRange = this.req.headers['if-range'] |
|
|
|
if (!ifRange) { |
|
return true |
|
} |
|
|
|
// if-range as etag |
|
if (ifRange.indexOf('"') !== -1) { |
|
var etag = this.res.getHeader('ETag') |
|
return Boolean(etag && ifRange.indexOf(etag) !== -1) |
|
} |
|
|
|
// if-range as modified date |
|
var lastModified = this.res.getHeader('Last-Modified') |
|
return parseHttpDate(lastModified) <= parseHttpDate(ifRange) |
|
} |
|
|
|
/** |
|
* Redirect to path. |
|
* |
|
* @param {string} path |
|
* @private |
|
*/ |
|
|
|
SendStream.prototype.redirect = function redirect (path) { |
|
var res = this.res |
|
|
|
if (hasListeners(this, 'directory')) { |
|
this.emit('directory', res, path) |
|
return |
|
} |
|
|
|
if (this.hasTrailingSlash()) { |
|
this.error(403) |
|
return |
|
} |
|
|
|
var loc = encodeUrl(collapseLeadingSlashes(this.path + '/')) |
|
var doc = createHtmlDocument('Redirecting', 'Redirecting to <a href="' + escapeHtml(loc) + '">' + |
|
escapeHtml(loc) + '</a>') |
|
|
|
// redirect |
|
res.statusCode = 301 |
|
res.setHeader('Content-Type', 'text/html; charset=UTF-8') |
|
res.setHeader('Content-Length', Buffer.byteLength(doc)) |
|
res.setHeader('Content-Security-Policy', "default-src 'self'") |
|
res.setHeader('X-Content-Type-Options', 'nosniff') |
|
res.setHeader('Location', loc) |
|
res.end(doc) |
|
} |
|
|
|
/** |
|
* Pipe to `res. |
|
* |
|
* @param {Stream} res |
|
* @return {Stream} res |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.pipe = function pipe (res) { |
|
// root path |
|
var root = this._root |
|
|
|
// references |
|
this.res = res |
|
|
|
// decode the path |
|
var path = decode(this.path) |
|
if (path === -1) { |
|
this.error(400) |
|
return res |
|
} |
|
|
|
// null byte(s) |
|
if (~path.indexOf('\0')) { |
|
this.error(400) |
|
return res |
|
} |
|
|
|
var parts |
|
if (root !== null) { |
|
// normalize |
|
if (path) { |
|
path = normalize('.' + sep + path) |
|
} |
|
|
|
// malicious path |
|
if (UP_PATH_REGEXP.test(path)) { |
|
debug('malicious path "%s"', path) |
|
this.error(403) |
|
return res |
|
} |
|
|
|
// explode path parts |
|
parts = path.split(sep) |
|
|
|
// join / normalize from optional root dir |
|
path = normalize(join(root, path)) |
|
root = normalize(root + sep) |
|
} else { |
|
// ".." is malicious without "root" |
|
if (UP_PATH_REGEXP.test(path)) { |
|
debug('malicious path "%s"', path) |
|
this.error(403) |
|
return res |
|
} |
|
|
|
// explode path parts |
|
parts = normalize(path).split(sep) |
|
|
|
// resolve the path |
|
path = resolve(path) |
|
} |
|
|
|
// dotfile handling |
|
if (containsDotFile(parts)) { |
|
var access = this._dotfiles |
|
|
|
// legacy support |
|
if (access === undefined) { |
|
access = parts[parts.length - 1][0] === '.' |
|
? (this._hidden ? 'allow' : 'ignore') |
|
: 'allow' |
|
} |
|
|
|
debug('%s dotfile "%s"', access, path) |
|
switch (access) { |
|
case 'allow': |
|
break |
|
case 'deny': |
|
this.error(403) |
|
return res |
|
case 'ignore': |
|
default: |
|
this.error(404) |
|
return res |
|
} |
|
} |
|
|
|
// index file support |
|
if (this._index.length && this.hasTrailingSlash()) { |
|
this.sendIndex(path) |
|
return res |
|
} |
|
|
|
this.sendFile(path) |
|
return res |
|
} |
|
|
|
/** |
|
* Transfer `path`. |
|
* |
|
* @param {String} path |
|
* @api public |
|
*/ |
|
|
|
SendStream.prototype.send = function send (path, stat) { |
|
var len = stat.size |
|
var options = this.options |
|
var opts = {} |
|
var res = this.res |
|
var req = this.req |
|
var ranges = req.headers.range |
|
var offset = options.start || 0 |
|
|
|
if (headersSent(res)) { |
|
// impossible to send now |
|
this.headersAlreadySent() |
|
return |
|
} |
|
|
|
debug('pipe "%s"', path) |
|
|
|
// set header fields |
|
this.setHeader(path, stat) |
|
|
|
// set content-type |
|
this.type(path) |
|
|
|
// conditional GET support |
|
if (this.isConditionalGET()) { |
|
if (this.isPreconditionFailure()) { |
|
this.error(412) |
|
return |
|
} |
|
|
|
if (this.isCachable() && this.isFresh()) { |
|
this.notModified() |
|
return |
|
} |
|
} |
|
|
|
// adjust len to start/end options |
|
len = Math.max(0, len - offset) |
|
if (options.end !== undefined) { |
|
var bytes = options.end - offset + 1 |
|
if (len > bytes) len = bytes |
|
} |
|
|
|
// Range support |
|
if (this._acceptRanges && BYTES_RANGE_REGEXP.test(ranges)) { |
|
// parse |
|
ranges = parseRange(len, ranges, { |
|
combine: true |
|
}) |
|
|
|
// If-Range support |
|
if (!this.isRangeFresh()) { |
|
debug('range stale') |
|
ranges = -2 |
|
} |
|
|
|
// unsatisfiable |
|
if (ranges === -1) { |
|
debug('range unsatisfiable') |
|
|
|
// Content-Range |
|
res.setHeader('Content-Range', contentRange('bytes', len)) |
|
|
|
// 416 Requested Range Not Satisfiable |
|
return this.error(416, { |
|
headers: {'Content-Range': res.getHeader('Content-Range')} |
|
}) |
|
} |
|
|
|
// valid (syntactically invalid/multiple ranges are treated as a regular response) |
|
if (ranges !== -2 && ranges.length === 1) { |
|
debug('range %j', ranges) |
|
|
|
// Content-Range |
|
res.statusCode = 206 |
|
res.setHeader('Content-Range', contentRange('bytes', len, ranges[0])) |
|
|
|
// adjust for requested range |
|
offset += ranges[0].start |
|
len = ranges[0].end - ranges[0].start + 1 |
|
} |
|
} |
|
|
|
// clone options |
|
for (var prop in options) { |
|
opts[prop] = options[prop] |
|
} |
|
|
|
// set read options |
|
opts.start = offset |
|
opts.end = Math.max(offset, offset + len - 1) |
|
|
|
// content-length |
|
res.setHeader('Content-Length', len) |
|
|
|
// HEAD support |
|
if (req.method === 'HEAD') { |
|
res.end() |
|
return |
|
} |
|
|
|
this.stream(path, opts) |
|
} |
|
|
|
/** |
|
* Transfer file for `path`. |
|
* |
|
* @param {String} path |
|
* @api private |
|
*/ |
|
SendStream.prototype.sendFile = function sendFile (path) { |
|
var i = 0 |
|
var self = this |
|
|
|
debug('stat "%s"', path) |
|
fs.stat(path, function onstat (err, stat) { |
|
if (err && err.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { |
|
// not found, check extensions |
|
return next(err) |
|
} |
|
if (err) return self.onStatError(err) |
|
if (stat.isDirectory()) return self.redirect(path) |
|
self.emit('file', path, stat) |
|
self.send(path, stat) |
|
}) |
|
|
|
function next (err) { |
|
if (self._extensions.length <= i) { |
|
return err |
|
? self.onStatError(err) |
|
: self.error(404) |
|
} |
|
|
|
var p = path + '.' + self._extensions[i++] |
|
|
|
debug('stat "%s"', p) |
|
fs.stat(p, function (err, stat) { |
|
if (err) return next(err) |
|
if (stat.isDirectory()) return next() |
|
self.emit('file', p, stat) |
|
self.send(p, stat) |
|
}) |
|
} |
|
} |
|
|
|
/** |
|
* Transfer index for `path`. |
|
* |
|
* @param {String} path |
|
* @api private |
|
*/ |
|
SendStream.prototype.sendIndex = function sendIndex (path) { |
|
var i = -1 |
|
var self = this |
|
|
|
function next (err) { |
|
if (++i >= self._index.length) { |
|
if (err) return self.onStatError(err) |
|
return self.error(404) |
|
} |
|
|
|
var p = join(path, self._index[i]) |
|
|
|
debug('stat "%s"', p) |
|
fs.stat(p, function (err, stat) { |
|
if (err) return next(err) |
|
if (stat.isDirectory()) return next() |
|
self.emit('file', p, stat) |
|
self.send(p, stat) |
|
}) |
|
} |
|
|
|
next() |
|
} |
|
|
|
/** |
|
* Stream `path` to the response. |
|
* |
|
* @param {String} path |
|
* @param {Object} options |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.stream = function stream (path, options) { |
|
// TODO: this is all lame, refactor meeee |
|
var finished = false |
|
var self = this |
|
var res = this.res |
|
|
|
// pipe |
|
var stream = fs.createReadStream(path, options) |
|
this.emit('stream', stream) |
|
stream.pipe(res) |
|
|
|
// response finished, done with the fd |
|
onFinished(res, function onfinished () { |
|
finished = true |
|
destroy(stream) |
|
}) |
|
|
|
// error handling code-smell |
|
stream.on('error', function onerror (err) { |
|
// request already finished |
|
if (finished) return |
|
|
|
// clean up stream |
|
finished = true |
|
destroy(stream) |
|
|
|
// error |
|
self.onStatError(err) |
|
}) |
|
|
|
// end |
|
stream.on('end', function onend () { |
|
self.emit('end') |
|
}) |
|
} |
|
|
|
/** |
|
* Set content-type based on `path` |
|
* if it hasn't been explicitly set. |
|
* |
|
* @param {String} path |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.type = function type (path) { |
|
var res = this.res |
|
|
|
if (res.getHeader('Content-Type')) return |
|
|
|
var type = mime.lookup(path) |
|
|
|
if (!type) { |
|
debug('no content-type') |
|
return |
|
} |
|
|
|
var charset = mime.charsets.lookup(type) |
|
|
|
debug('content-type %s', type) |
|
res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : '')) |
|
} |
|
|
|
/** |
|
* Set response header fields, most |
|
* fields may be pre-defined. |
|
* |
|
* @param {String} path |
|
* @param {Object} stat |
|
* @api private |
|
*/ |
|
|
|
SendStream.prototype.setHeader = function setHeader (path, stat) { |
|
var res = this.res |
|
|
|
this.emit('headers', res, path, stat) |
|
|
|
if (this._acceptRanges && !res.getHeader('Accept-Ranges')) { |
|
debug('accept ranges') |
|
res.setHeader('Accept-Ranges', 'bytes') |
|
} |
|
|
|
if (this._cacheControl && !res.getHeader('Cache-Control')) { |
|
var cacheControl = 'public, max-age=' + Math.floor(this._maxage / 1000) |
|
|
|
if (this._immutable) { |
|
cacheControl += ', immutable' |
|
} |
|
|
|
debug('cache-control %s', cacheControl) |
|
res.setHeader('Cache-Control', cacheControl) |
|
} |
|
|
|
if (this._lastModified && !res.getHeader('Last-Modified')) { |
|
var modified = stat.mtime.toUTCString() |
|
debug('modified %s', modified) |
|
res.setHeader('Last-Modified', modified) |
|
} |
|
|
|
if (this._etag && !res.getHeader('ETag')) { |
|
var val = etag(stat) |
|
debug('etag %s', val) |
|
res.setHeader('ETag', val) |
|
} |
|
} |
|
|
|
/** |
|
* Clear all headers from a response. |
|
* |
|
* @param {object} res |
|
* @private |
|
*/ |
|
|
|
function clearHeaders (res) { |
|
var headers = getHeaderNames(res) |
|
|
|
for (var i = 0; i < headers.length; i++) { |
|
res.removeHeader(headers[i]) |
|
} |
|
} |
|
|
|
/** |
|
* Collapse all leading slashes into a single slash |
|
* |
|
* @param {string} str |
|
* @private |
|
*/ |
|
function collapseLeadingSlashes (str) { |
|
for (var i = 0; i < str.length; i++) { |
|
if (str[i] !== '/') { |
|
break |
|
} |
|
} |
|
|
|
return i > 1 |
|
? '/' + str.substr(i) |
|
: str |
|
} |
|
|
|
/** |
|
* Determine if path parts contain a dotfile. |
|
* |
|
* @api private |
|
*/ |
|
|
|
function containsDotFile (parts) { |
|
for (var i = 0; i < parts.length; i++) { |
|
var part = parts[i] |
|
if (part.length > 1 && part[0] === '.') { |
|
return true |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
/** |
|
* Create a Content-Range header. |
|
* |
|
* @param {string} type |
|
* @param {number} size |
|
* @param {array} [range] |
|
*/ |
|
|
|
function contentRange (type, size, range) { |
|
return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size |
|
} |
|
|
|
/** |
|
* Create a minimal HTML document. |
|
* |
|
* @param {string} title |
|
* @param {string} body |
|
* @private |
|
*/ |
|
|
|
function createHtmlDocument (title, body) { |
|
return '<!DOCTYPE html>\n' + |
|
'<html lang="en">\n' + |
|
'<head>\n' + |
|
'<meta charset="utf-8">\n' + |
|
'<title>' + title + '</title>\n' + |
|
'</head>\n' + |
|
'<body>\n' + |
|
'<pre>' + body + '</pre>\n' + |
|
'</body>\n' + |
|
'</html>\n' |
|
} |
|
|
|
/** |
|
* decodeURIComponent. |
|
* |
|
* Allows V8 to only deoptimize this fn instead of all |
|
* of send(). |
|
* |
|
* @param {String} path |
|
* @api private |
|
*/ |
|
|
|
function decode (path) { |
|
try { |
|
return decodeURIComponent(path) |
|
} catch (err) { |
|
return -1 |
|
} |
|
} |
|
|
|
/** |
|
* Get the header names on a respnse. |
|
* |
|
* @param {object} res |
|
* @returns {array[string]} |
|
* @private |
|
*/ |
|
|
|
function getHeaderNames (res) { |
|
return typeof res.getHeaderNames !== 'function' |
|
? Object.keys(res._headers || {}) |
|
: res.getHeaderNames() |
|
} |
|
|
|
/** |
|
* Determine if emitter has listeners of a given type. |
|
* |
|
* The way to do this check is done three different ways in Node.js >= 0.8 |
|
* so this consolidates them into a minimal set using instance methods. |
|
* |
|
* @param {EventEmitter} emitter |
|
* @param {string} type |
|
* @returns {boolean} |
|
* @private |
|
*/ |
|
|
|
function hasListeners (emitter, type) { |
|
var count = typeof emitter.listenerCount !== 'function' |
|
? emitter.listeners(type).length |
|
: emitter.listenerCount(type) |
|
|
|
return count > 0 |
|
} |
|
|
|
/** |
|
* Determine if the response headers have been sent. |
|
* |
|
* @param {object} res |
|
* @returns {boolean} |
|
* @private |
|
*/ |
|
|
|
function headersSent (res) { |
|
return typeof res.headersSent !== 'boolean' |
|
? Boolean(res._header) |
|
: res.headersSent |
|
} |
|
|
|
/** |
|
* Normalize the index option into an array. |
|
* |
|
* @param {boolean|string|array} val |
|
* @param {string} name |
|
* @private |
|
*/ |
|
|
|
function normalizeList (val, name) { |
|
var list = [].concat(val || []) |
|
|
|
for (var i = 0; i < list.length; i++) { |
|
if (typeof list[i] !== 'string') { |
|
throw new TypeError(name + ' must be array of strings or false') |
|
} |
|
} |
|
|
|
return list |
|
} |
|
|
|
/** |
|
* Parse an HTTP Date into a number. |
|
* |
|
* @param {string} date |
|
* @private |
|
*/ |
|
|
|
function parseHttpDate (date) { |
|
var timestamp = date && Date.parse(date) |
|
|
|
return typeof timestamp === 'number' |
|
? timestamp |
|
: NaN |
|
} |
|
|
|
/** |
|
* Parse a HTTP token list. |
|
* |
|
* @param {string} str |
|
* @private |
|
*/ |
|
|
|
function parseTokenList (str) { |
|
var end = 0 |
|
var list = [] |
|
var start = 0 |
|
|
|
// gather tokens |
|
for (var i = 0, len = str.length; i < len; i++) { |
|
switch (str.charCodeAt(i)) { |
|
case 0x20: /* */ |
|
if (start === end) { |
|
start = end = i + 1 |
|
} |
|
break |
|
case 0x2c: /* , */ |
|
list.push(str.substring(start, end)) |
|
start = end = i + 1 |
|
break |
|
default: |
|
end = i + 1 |
|
break |
|
} |
|
} |
|
|
|
// final token |
|
list.push(str.substring(start, end)) |
|
|
|
return list |
|
} |
|
|
|
/** |
|
* Set an object of headers on a response. |
|
* |
|
* @param {object} res |
|
* @param {object} headers |
|
* @private |
|
*/ |
|
|
|
function setHeaders (res, headers) { |
|
var keys = Object.keys(headers) |
|
|
|
for (var i = 0; i < keys.length; i++) { |
|
var key = keys[i] |
|
res.setHeader(key, headers[key]) |
|
} |
|
}
|
|
|