6 var debug = require('debug')('send')
7 var deprecate = require('depd')('send')
8 var destroy = require('destroy')
9 var escapeHtml = require('escape-html')
10 , parseRange = require('range-parser')
11 , Stream = require('stream')
12 , mime = require('mime')
13 , fresh = require('fresh')
14 , path = require('path')
15 , http = require('http')
17 , normalize = path.normalize
19 var etag = require('etag')
20 var EventEmitter = require('events').EventEmitter;
21 var ms = require('ms');
22 var onFinished = require('on-finished')
27 var extname = path.extname
28 var maxMaxAge = 60 * 60 * 24 * 365 * 1000; // 1 year
29 var resolve = path.resolve
31 var toString = Object.prototype.toString
32 var upPathRegexp = /(?:^|[\\\/])\.\.(?:[\\\/]|$)/
38 exports = module.exports = send;
47 * Shim EventEmitter.listenerCount for node.js < 0.10
50 /* istanbul ignore next */
51 var listenerCount = EventEmitter.listenerCount
52 || function(emitter, type){ return emitter.listeners(type).length; };
55 * Return a `SendStream` for `req` and `path`.
57 * @param {Request} req
58 * @param {String} path
59 * @param {Object} options
60 * @return {SendStream}
64 function send(req, path, options) {
65 return new SendStream(req, path, options);
69 * Initialize a `SendStream` with the given `path`.
71 * @param {Request} req
72 * @param {String} path
73 * @param {Object} options
77 function SendStream(req, path, options) {
79 options = options || {};
82 this.options = options;
84 this._etag = options.etag !== undefined
85 ? Boolean(options.etag)
88 this._dotfiles = options.dotfiles !== undefined
92 if (['allow', 'deny', 'ignore'].indexOf(this._dotfiles) === -1) {
93 throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"')
96 this._hidden = Boolean(options.hidden)
98 if ('hidden' in options) {
99 deprecate('hidden: use dotfiles: \'' + (this._hidden ? 'allow' : 'ignore') + '\' instead')
103 if (!('dotfiles' in options)) {
104 this._dotfiles = undefined
107 this._extensions = options.extensions !== undefined
108 ? normalizeList(options.extensions)
111 this._index = options.index !== undefined
112 ? normalizeList(options.index)
115 this._lastModified = options.lastModified !== undefined
116 ? Boolean(options.lastModified)
119 this._maxage = options.maxAge || options.maxage
120 this._maxage = typeof this._maxage === 'string'
122 : Number(this._maxage)
123 this._maxage = !isNaN(this._maxage)
124 ? Math.min(Math.max(0, this._maxage), maxMaxAge)
127 this._root = options.root
128 ? resolve(options.root)
131 if (!this._root && options.from) {
132 this.from(options.from);
137 * Inherits from `Stream.prototype`.
140 SendStream.prototype.__proto__ = Stream.prototype;
143 * Enable or disable etag generation.
145 * @param {Boolean} val
146 * @return {SendStream}
150 SendStream.prototype.etag = deprecate.function(function etag(val) {
152 debug('etag %s', val);
155 }, 'send.etag: pass etag as option');
158 * Enable or disable "hidden" (dot) files.
160 * @param {Boolean} path
161 * @return {SendStream}
165 SendStream.prototype.hidden = deprecate.function(function hidden(val) {
167 debug('hidden %s', val);
169 this._dotfiles = undefined
171 }, 'send.hidden: use dotfiles option');
174 * Set index `paths`, set to a falsy
175 * value to disable index support.
177 * @param {String|Boolean|Array} paths
178 * @return {SendStream}
182 SendStream.prototype.index = deprecate.function(function index(paths) {
183 var index = !paths ? [] : normalizeList(paths);
184 debug('index %o', paths);
187 }, 'send.index: pass index as option');
192 * @param {String} path
193 * @return {SendStream}
197 SendStream.prototype.root = function(path){
199 this._root = resolve(path)
203 SendStream.prototype.from = deprecate.function(SendStream.prototype.root,
204 'send.from: pass root as option');
206 SendStream.prototype.root = deprecate.function(SendStream.prototype.root,
207 'send.root: pass root as option');
210 * Set max-age to `maxAge`.
212 * @param {Number} maxAge
213 * @return {SendStream}
217 SendStream.prototype.maxage = deprecate.function(function maxage(maxAge) {
218 maxAge = typeof maxAge === 'string'
221 if (isNaN(maxAge)) maxAge = 0;
222 if (Infinity == maxAge) maxAge = 60 * 60 * 24 * 365 * 1000;
223 debug('max-age %d', maxAge);
224 this._maxage = maxAge;
226 }, 'send.maxage: pass maxAge as option');
229 * Emit error with `status`.
231 * @param {Number} status
235 SendStream.prototype.error = function(status, err){
237 var msg = http.STATUS_CODES[status];
239 err = err || new Error(msg);
242 // emit if listeners instead of responding
243 if (listenerCount(this, 'error') !== 0) {
244 return this.emit('error', err);
247 // wipe all existing headers
248 res._headers = undefined;
250 res.statusCode = err.status;
255 * Check if the pathname ends with "/".
261 SendStream.prototype.hasTrailingSlash = function(){
262 return '/' == this.path[this.path.length - 1];
266 * Check if this is a conditional GET request.
272 SendStream.prototype.isConditionalGET = function(){
273 return this.req.headers['if-none-match']
274 || this.req.headers['if-modified-since'];
278 * Strip content-* header fields.
283 SendStream.prototype.removeContentHeaderFields = function(){
285 Object.keys(res._headers).forEach(function(field){
286 if (0 == field.indexOf('content')) {
287 res.removeHeader(field);
293 * Respond with 304 not modified.
298 SendStream.prototype.notModified = function(){
300 debug('not modified');
301 this.removeContentHeaderFields();
302 res.statusCode = 304;
307 * Raise error that headers already sent.
312 SendStream.prototype.headersAlreadySent = function headersAlreadySent(){
313 var err = new Error('Can\'t set headers after they are sent.');
314 debug('headers already sent');
315 this.error(500, err);
319 * Check if the request is cacheable, aka
320 * responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}).
326 SendStream.prototype.isCachable = function(){
328 return (res.statusCode >= 200 && res.statusCode < 300) || 304 == res.statusCode;
332 * Handle stat() error.
338 SendStream.prototype.onStatError = function(err){
339 var notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'];
340 if (~notfound.indexOf(err.code)) return this.error(404, err);
341 this.error(500, err);
345 * Check if the cache is fresh.
351 SendStream.prototype.isFresh = function(){
352 return fresh(this.req.headers, this.res._headers);
356 * Check if the range is fresh.
362 SendStream.prototype.isRangeFresh = function isRangeFresh(){
363 var ifRange = this.req.headers['if-range'];
365 if (!ifRange) return true;
367 return ~ifRange.indexOf('"')
368 ? ~ifRange.indexOf(this.res._headers['etag'])
369 : Date.parse(this.res._headers['last-modified']) <= Date.parse(ifRange);
373 * Redirect to `path`.
375 * @param {String} path
379 SendStream.prototype.redirect = function(path){
380 if (listenerCount(this, 'directory') !== 0) {
381 return this.emit('directory');
384 if (this.hasTrailingSlash()) return this.error(403);
387 res.statusCode = 301;
388 res.setHeader('Content-Type', 'text/html; charset=utf-8');
389 res.setHeader('Location', path);
390 res.end('Redirecting to <a href="' + escapeHtml(path) + '">' + escapeHtml(path) + '</a>\n');
396 * @param {Stream} res
397 * @return {Stream} res
401 SendStream.prototype.pipe = function(res){
410 var path = decode(this.path)
411 if (path === -1) return this.error(400)
414 if (~path.indexOf('\0')) return this.error(400);
419 if (upPathRegexp.test(normalize('.' + sep + path))) {
420 debug('malicious path "%s"', path)
421 return this.error(403)
424 // join / normalize from optional root dir
425 path = normalize(join(root, path))
426 root = normalize(root + sep)
428 // explode path parts
429 parts = path.substr(root.length).split(sep)
431 // ".." is malicious without "root"
432 if (upPathRegexp.test(path)) {
433 debug('malicious path "%s"', path)
434 return this.error(403)
437 // explode path parts
438 parts = normalize(path).split(sep)
445 if (containsDotFile(parts)) {
446 var access = this._dotfiles
449 if (access === undefined) {
450 access = parts[parts.length - 1][0] === '.'
451 ? (this._hidden ? 'allow' : 'ignore')
455 debug('%s dotfile "%s"', access, path)
460 return this.error(403)
463 return this.error(404)
467 // index file support
468 if (this._index.length && this.path[this.path.length - 1] === '/') {
469 this.sendIndex(path);
480 * @param {String} path
484 SendStream.prototype.send = function(path, stat){
485 var options = this.options;
489 var ranges = req.headers.range;
490 var offset = options.start || 0;
493 // impossible to send now
494 return this.headersAlreadySent();
497 debug('pipe "%s"', path)
500 this.setHeader(path, stat);
505 // conditional GET support
506 if (this.isConditionalGET()
509 return this.notModified();
512 // adjust len to start/end options
513 len = Math.max(0, len - offset);
514 if (options.end !== undefined) {
515 var bytes = options.end - offset + 1;
516 if (len > bytes) len = bytes;
521 ranges = parseRange(len, ranges);
524 if (!this.isRangeFresh()) {
525 debug('range stale');
531 debug('range unsatisfiable');
532 res.setHeader('Content-Range', 'bytes */' + stat.size);
533 return this.error(416);
536 // valid (syntactically invalid/multiple ranges are treated as a regular response)
537 if (-2 != ranges && ranges.length === 1) {
538 debug('range %j', ranges);
540 options.start = offset + ranges[0].start;
541 options.end = offset + ranges[0].end;
544 res.statusCode = 206;
545 res.setHeader('Content-Range', 'bytes '
551 len = options.end - options.start + 1;
556 res.setHeader('Content-Length', len);
559 if ('HEAD' == req.method) return res.end();
561 this.stream(path, options);
565 * Transfer file for `path`.
567 * @param {String} path
570 SendStream.prototype.sendFile = function sendFile(path) {
574 debug('stat "%s"', path);
575 fs.stat(path, function onstat(err, stat) {
576 if (err && err.code === 'ENOENT'
578 && path[path.length - 1] !== sep) {
579 // not found, check extensions
582 if (err) return self.onStatError(err)
583 if (stat.isDirectory()) return self.redirect(self.path)
584 self.emit('file', path, stat)
585 self.send(path, stat)
589 if (self._extensions.length <= i) {
591 ? self.onStatError(err)
595 var p = path + '.' + self._extensions[i++]
597 debug('stat "%s"', p)
598 fs.stat(p, function (err, stat) {
599 if (err) return next(err)
600 if (stat.isDirectory()) return next()
601 self.emit('file', p, stat)
608 * Transfer index for `path`.
610 * @param {String} path
613 SendStream.prototype.sendIndex = function sendIndex(path){
618 if (++i >= self._index.length) {
619 if (err) return self.onStatError(err);
620 return self.error(404);
623 var p = join(path, self._index[i]);
625 debug('stat "%s"', p);
626 fs.stat(p, function(err, stat){
627 if (err) return next(err);
628 if (stat.isDirectory()) return next();
629 self.emit('file', p, stat);
638 * Stream `path` to the response.
640 * @param {String} path
641 * @param {Object} options
645 SendStream.prototype.stream = function(path, options){
646 // TODO: this is all lame, refactor meeee
647 var finished = false;
653 var stream = fs.createReadStream(path, options);
654 this.emit('stream', stream);
657 // response finished, done with the fd
658 onFinished(res, function onfinished(){
663 // error handling code-smell
664 stream.on('error', function onerror(err){
665 // request already finished
666 if (finished) return;
673 self.onStatError(err);
677 stream.on('end', function onend(){
683 * Set content-type based on `path`
684 * if it hasn't been explicitly set.
686 * @param {String} path
690 SendStream.prototype.type = function(path){
692 if (res.getHeader('Content-Type')) return;
693 var type = mime.lookup(path);
694 var charset = mime.charsets.lookup(type);
695 debug('content-type %s', type);
696 res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : ''));
700 * Set response header fields, most
701 * fields may be pre-defined.
703 * @param {String} path
704 * @param {Object} stat
708 SendStream.prototype.setHeader = function setHeader(path, stat){
711 this.emit('headers', res, path, stat);
713 if (!res.getHeader('Accept-Ranges')) res.setHeader('Accept-Ranges', 'bytes');
714 if (!res.getHeader('Date')) res.setHeader('Date', new Date().toUTCString());
715 if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', 'public, max-age=' + Math.floor(this._maxage / 1000));
717 if (this._lastModified && !res.getHeader('Last-Modified')) {
718 var modified = stat.mtime.toUTCString()
719 debug('modified %s', modified)
720 res.setHeader('Last-Modified', modified)
723 if (this._etag && !res.getHeader('ETag')) {
725 debug('etag %s', val)
726 res.setHeader('ETag', val)
731 * Determine if path parts contain a dotfile.
736 function containsDotFile(parts) {
737 for (var i = 0; i < parts.length; i++) {
738 if (parts[i][0] === '.') {
747 * decodeURIComponent.
749 * Allows V8 to only deoptimize this fn instead of all
752 * @param {String} path
756 function decode(path) {
758 return decodeURIComponent(path)
765 * Normalize the index option into an array.
767 * @param {boolean|string|array} val
771 function normalizeList(val){
772 return [].concat(val || [])