| /*! |
| * send |
| * Copyright(c) 2012 TJ Holowaychuk |
| * Copyright(c) 2014-2015 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 escapeHtml = require('escape-html') |
| , parseRange = require('range-parser') |
| , Stream = require('stream') |
| , mime = require('mime') |
| , fresh = require('fresh') |
| , path = require('path') |
| , fs = require('fs') |
| , normalize = path.normalize |
| , join = path.join |
| var etag = require('etag') |
| var EventEmitter = require('events').EventEmitter; |
| var ms = require('ms'); |
| var onFinished = require('on-finished') |
| var statuses = require('statuses') |
| |
| /** |
| * Variables. |
| */ |
| var extname = path.extname |
| var maxMaxAge = 60 * 60 * 24 * 365 * 1000; // 1 year |
| var resolve = path.resolve |
| var sep = path.sep |
| var toString = Object.prototype.toString |
| var upPathRegexp = /(?:^|[\\\/])\.\.(?:[\\\/]|$)/ |
| |
| /** |
| * Module exports. |
| * @public |
| */ |
| |
| module.exports = send |
| module.exports.mime = mime |
| |
| /** |
| * Shim EventEmitter.listenerCount for node.js < 0.10 |
| */ |
| |
| /* istanbul ignore next */ |
| var listenerCount = EventEmitter.listenerCount |
| || function(emitter, type){ return emitter.listeners(type).length; }; |
| |
| /** |
| * 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) { |
| var opts = options || {} |
| |
| this.options = opts |
| this.path = path |
| this.req = req |
| |
| 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._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), maxMaxAge) |
| : 0 |
| |
| this._root = opts.root |
| ? resolve(opts.root) |
| : null |
| |
| if (!this._root && opts.from) { |
| this.from(opts.from) |
| } |
| } |
| |
| /** |
| * Inherits from `Stream.prototype`. |
| */ |
| |
| SendStream.prototype.__proto__ = Stream.prototype; |
| |
| /** |
| * Enable or disable etag generation. |
| * |
| * @param {Boolean} val |
| * @return {SendStream} |
| * @api public |
| */ |
| |
| SendStream.prototype.etag = deprecate.function(function etag(val) { |
| val = Boolean(val); |
| debug('etag %s', val); |
| this._etag = val; |
| 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) { |
| val = Boolean(val); |
| debug('hidden %s', val); |
| this._hidden = val; |
| this._dotfiles = undefined |
| 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(path){ |
| path = String(path); |
| this._root = resolve(path) |
| 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) { |
| maxAge = typeof maxAge === 'string' |
| ? ms(maxAge) |
| : Number(maxAge); |
| if (isNaN(maxAge)) maxAge = 0; |
| if (Infinity == maxAge) maxAge = 60 * 60 * 24 * 365 * 1000; |
| debug('max-age %d', maxAge); |
| this._maxage = maxAge; |
| return this; |
| }, 'send.maxage: pass maxAge as option'); |
| |
| /** |
| * Emit error with `status`. |
| * |
| * @param {number} status |
| * @param {Error} [error] |
| * @private |
| */ |
| |
| SendStream.prototype.error = function error(status, error) { |
| // emit if listeners instead of responding |
| if (listenerCount(this, 'error') !== 0) { |
| return this.emit('error', createError(error, status, { |
| expose: false |
| })) |
| } |
| |
| var res = this.res |
| var msg = statuses[status] |
| |
| // wipe all existing headers |
| res._headers = null |
| |
| // send basic response |
| res.statusCode = status |
| res.setHeader('Content-Type', 'text/plain; charset=UTF-8') |
| res.setHeader('Content-Length', Buffer.byteLength(msg)) |
| res.setHeader('X-Content-Type-Options', 'nosniff') |
| res.end(msg) |
| } |
| |
| /** |
| * Check if the pathname ends with "/". |
| * |
| * @return {Boolean} |
| * @api private |
| */ |
| |
| SendStream.prototype.hasTrailingSlash = function(){ |
| return '/' == this.path[this.path.length - 1]; |
| }; |
| |
| /** |
| * Check if this is a conditional GET request. |
| * |
| * @return {Boolean} |
| * @api private |
| */ |
| |
| SendStream.prototype.isConditionalGET = function(){ |
| return this.req.headers['if-none-match'] |
| || this.req.headers['if-modified-since']; |
| }; |
| |
| /** |
| * Strip content-* header fields. |
| * |
| * @private |
| */ |
| |
| SendStream.prototype.removeContentHeaderFields = function removeContentHeaderFields() { |
| var res = this.res |
| var headers = Object.keys(res._headers || {}) |
| |
| 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(){ |
| 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(){ |
| var res = this.res; |
| return (res.statusCode >= 200 && res.statusCode < 300) || 304 == res.statusCode; |
| }; |
| |
| /** |
| * 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(){ |
| return fresh(this.req.headers, this.res._headers); |
| }; |
| |
| /** |
| * 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; |
| |
| return ~ifRange.indexOf('"') |
| ? ~ifRange.indexOf(this.res._headers['etag']) |
| : Date.parse(this.res._headers['last-modified']) <= Date.parse(ifRange); |
| }; |
| |
| /** |
| * Redirect to path. |
| * |
| * @param {string} path |
| * @private |
| */ |
| |
| SendStream.prototype.redirect = function redirect(path) { |
| if (listenerCount(this, 'directory') !== 0) { |
| this.emit('directory') |
| return |
| } |
| |
| if (this.hasTrailingSlash()) { |
| this.error(403) |
| return |
| } |
| |
| var loc = path + '/' |
| var msg = 'Redirecting to <a href="' + escapeHtml(loc) + '">' + escapeHtml(loc) + '</a>\n' |
| var res = this.res |
| |
| // redirect |
| res.statusCode = 301 |
| res.setHeader('Content-Type', 'text/html; charset=UTF-8') |
| res.setHeader('Content-Length', Buffer.byteLength(msg)) |
| res.setHeader('X-Content-Type-Options', 'nosniff') |
| res.setHeader('Location', loc) |
| res.end(msg) |
| } |
| |
| /** |
| * Pipe to `res. |
| * |
| * @param {Stream} res |
| * @return {Stream} res |
| * @api public |
| */ |
| |
| SendStream.prototype.pipe = function(res){ |
| var self = this |
| , args = arguments |
| , root = this._root; |
| |
| // references |
| this.res = res; |
| |
| // decode the path |
| var path = decode(this.path) |
| if (path === -1) return this.error(400) |
| |
| // null byte(s) |
| if (~path.indexOf('\0')) return this.error(400); |
| |
| var parts |
| if (root !== null) { |
| // malicious path |
| if (upPathRegexp.test(normalize('.' + sep + path))) { |
| debug('malicious path "%s"', path) |
| return this.error(403) |
| } |
| |
| // join / normalize from optional root dir |
| path = normalize(join(root, path)) |
| root = normalize(root + sep) |
| |
| // explode path parts |
| parts = path.substr(root.length).split(sep) |
| } else { |
| // ".." is malicious without "root" |
| if (upPathRegexp.test(path)) { |
| debug('malicious path "%s"', path) |
| return this.error(403) |
| } |
| |
| // 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': |
| return this.error(403) |
| case 'ignore': |
| default: |
| return this.error(404) |
| } |
| } |
| |
| // index file support |
| if (this._index.length && this.path[this.path.length - 1] === '/') { |
| this.sendIndex(path); |
| return res; |
| } |
| |
| this.sendFile(path); |
| return res; |
| }; |
| |
| /** |
| * Transfer `path`. |
| * |
| * @param {String} path |
| * @api public |
| */ |
| |
| SendStream.prototype.send = function(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 (res._header) { |
| // impossible to send now |
| return this.headersAlreadySent(); |
| } |
| |
| debug('pipe "%s"', path) |
| |
| // set header fields |
| this.setHeader(path, stat); |
| |
| // set content-type |
| this.type(path); |
| |
| // conditional GET support |
| if (this.isConditionalGET() |
| && this.isCachable() |
| && this.isFresh()) { |
| return this.notModified(); |
| } |
| |
| // 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 (ranges) { |
| ranges = parseRange(len, ranges); |
| |
| // If-Range support |
| if (!this.isRangeFresh()) { |
| debug('range stale'); |
| ranges = -2; |
| } |
| |
| // unsatisfiable |
| if (-1 == ranges) { |
| debug('range unsatisfiable'); |
| res.setHeader('Content-Range', 'bytes */' + stat.size); |
| return this.error(416); |
| } |
| |
| // valid (syntactically invalid/multiple ranges are treated as a regular response) |
| if (-2 != ranges && ranges.length === 1) { |
| debug('range %j', ranges); |
| |
| // Content-Range |
| res.statusCode = 206; |
| res.setHeader('Content-Range', 'bytes ' |
| + ranges[0].start |
| + '-' |
| + ranges[0].end |
| + '/' |
| + len); |
| |
| 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 ('HEAD' == req.method) return res.end(); |
| |
| 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(self.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(path, options){ |
| // TODO: this is all lame, refactor meeee |
| var finished = false; |
| var self = this; |
| var res = this.res; |
| var req = this.req; |
| |
| // 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(path){ |
| var res = this.res; |
| if (res.getHeader('Content-Type')) return; |
| var type = mime.lookup(path); |
| 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 (!res.getHeader('Accept-Ranges')) res.setHeader('Accept-Ranges', 'bytes'); |
| if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', 'public, max-age=' + Math.floor(this._maxage / 1000)); |
| |
| 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) |
| } |
| }; |
| |
| /** |
| * Determine if path parts contain a dotfile. |
| * |
| * @api private |
| */ |
| |
| function containsDotFile(parts) { |
| for (var i = 0; i < parts.length; i++) { |
| if (parts[i][0] === '.') { |
| return true |
| } |
| } |
| |
| return false |
| } |
| |
| /** |
| * 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 |
| } |
| } |
| |
| /** |
| * 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 |
| } |