UNPKG

14.3 kBJavaScriptView Raw
1
2/**
3 * Module dependencies.
4 */
5
6var debug = require('debug')('send')
7var deprecate = require('depd')('send')
8var escapeHtml = require('escape-html')
9 , parseRange = require('range-parser')
10 , Stream = require('stream')
11 , mime = require('mime')
12 , fresh = require('fresh')
13 , path = require('path')
14 , http = require('http')
15 , onFinished = require('finished')
16 , fs = require('fs')
17 , basename = path.basename
18 , normalize = path.normalize
19 , join = path.join
20 , utils = require('./utils');
21var EventEmitter = require('events').EventEmitter;
22var ms = require('ms');
23
24/**
25 * Variables.
26 */
27var maxMaxAge = 60 * 60 * 24 * 365 * 1000; // 1 year
28var upPathRegexp = /(?:^|[\\\/])\.\.(?:[\\\/]|$)/;
29
30/**
31 * Expose `send`.
32 */
33
34exports = module.exports = send;
35
36/**
37 * Expose mime module.
38 */
39
40exports.mime = mime;
41
42/**
43 * Shim EventEmitter.listenerCount for node.js < 0.10
44 */
45
46/* istanbul ignore next */
47var listenerCount = EventEmitter.listenerCount
48 || function(emitter, type){ return emitter.listeners(type).length; };
49
50/**
51 * Return a `SendStream` for `req` and `path`.
52 *
53 * @param {Request} req
54 * @param {String} path
55 * @param {Object} options
56 * @return {SendStream}
57 * @api public
58 */
59
60function send(req, path, options) {
61 return new SendStream(req, path, options);
62}
63
64/**
65 * Initialize a `SendStream` with the given `path`.
66 *
67 * Events:
68 *
69 * - `error` an error occurred
70 * - `stream` file streaming has started
71 * - `end` streaming has completed
72 * - `directory` a directory was requested
73 *
74 * @param {Request} req
75 * @param {String} path
76 * @param {Object} options
77 * @api private
78 */
79
80function SendStream(req, path, options) {
81 var self = this;
82 options = options || {};
83 this.req = req;
84 this.path = path;
85 this.options = options;
86
87 this._etag = options.etag !== undefined
88 ? Boolean(options.etag)
89 : true
90
91 this._hidden = Boolean(options.hidden)
92
93 this._index = options.index !== undefined
94 ? normalizeIndex(options.index)
95 : ['index.html']
96
97 this._maxage = options.maxAge || options.maxage
98 this._maxage = typeof this._maxage === 'string'
99 ? ms(this._maxage)
100 : Number(this._maxage)
101 this._maxage = !isNaN(this._maxage)
102 ? Math.min(Math.max(0, this._maxage), maxMaxAge)
103 : 0
104
105 this._root = options.root
106 ? normalize(options.root)
107 : null
108
109 if (!this._root && options.from) {
110 this.from(options.from);
111 }
112}
113
114/**
115 * Inherits from `Stream.prototype`.
116 */
117
118SendStream.prototype.__proto__ = Stream.prototype;
119
120/**
121 * Enable or disable etag generation.
122 *
123 * @param {Boolean} val
124 * @return {SendStream}
125 * @api public
126 */
127
128SendStream.prototype.etag = deprecate.function(function etag(val) {
129 val = Boolean(val);
130 debug('etag %s', val);
131 this._etag = val;
132 return this;
133}, 'send.etag: pass etag as option');
134
135/**
136 * Enable or disable "hidden" (dot) files.
137 *
138 * @param {Boolean} path
139 * @return {SendStream}
140 * @api public
141 */
142
143SendStream.prototype.hidden = deprecate.function(function hidden(val) {
144 val = Boolean(val);
145 debug('hidden %s', val);
146 this._hidden = val;
147 return this;
148}, 'send.hidden: pass hidden as option');
149
150/**
151 * Set index `paths`, set to a falsy
152 * value to disable index support.
153 *
154 * @param {String|Boolean|Array} paths
155 * @return {SendStream}
156 * @api public
157 */
158
159SendStream.prototype.index = deprecate.function(function index(paths) {
160 var index = !paths ? [] : normalizeIndex(paths);
161 debug('index %o', paths);
162 this._index = index;
163 return this;
164}, 'send.index: pass index as option');
165
166/**
167 * Set root `path`.
168 *
169 * @param {String} path
170 * @return {SendStream}
171 * @api public
172 */
173
174SendStream.prototype.root = function(path){
175 path = String(path);
176 this._root = normalize(path);
177 return this;
178};
179
180SendStream.prototype.from = deprecate.function(SendStream.prototype.root,
181 'send.from: pass root as option');
182
183SendStream.prototype.root = deprecate.function(SendStream.prototype.root,
184 'send.root: pass root as option');
185
186/**
187 * Set max-age to `maxAge`.
188 *
189 * @param {Number} maxAge
190 * @return {SendStream}
191 * @api public
192 */
193
194SendStream.prototype.maxage = deprecate.function(function maxage(maxAge) {
195 maxAge = typeof maxAge === 'string'
196 ? ms(maxAge)
197 : Number(maxAge);
198 if (isNaN(maxAge)) maxAge = 0;
199 if (Infinity == maxAge) maxAge = 60 * 60 * 24 * 365 * 1000;
200 debug('max-age %d', maxAge);
201 this._maxage = maxAge;
202 return this;
203}, 'send.maxage: pass maxAge as option');
204
205/**
206 * Emit error with `status`.
207 *
208 * @param {Number} status
209 * @api private
210 */
211
212SendStream.prototype.error = function(status, err){
213 var res = this.res;
214 var msg = http.STATUS_CODES[status];
215
216 err = err || new Error(msg);
217 err.status = status;
218
219 // emit if listeners instead of responding
220 if (listenerCount(this, 'error') !== 0) {
221 return this.emit('error', err);
222 }
223
224 // wipe all existing headers
225 res._headers = undefined;
226
227 res.statusCode = err.status;
228 res.end(msg);
229};
230
231/**
232 * Check if the pathname is potentially malicious.
233 *
234 * @return {Boolean}
235 * @api private
236 */
237
238SendStream.prototype.isMalicious = function(){
239 return !this._root && ~this.path.indexOf('..') && upPathRegexp.test(this.path);
240};
241
242/**
243 * Check if the pathname ends with "/".
244 *
245 * @return {Boolean}
246 * @api private
247 */
248
249SendStream.prototype.hasTrailingSlash = function(){
250 return '/' == this.path[this.path.length - 1];
251};
252
253/**
254 * Check if the basename leads with ".".
255 *
256 * @return {Boolean}
257 * @api private
258 */
259
260SendStream.prototype.hasLeadingDot = function(){
261 return '.' == basename(this.path)[0];
262};
263
264/**
265 * Check if this is a conditional GET request.
266 *
267 * @return {Boolean}
268 * @api private
269 */
270
271SendStream.prototype.isConditionalGET = function(){
272 return this.req.headers['if-none-match']
273 || this.req.headers['if-modified-since'];
274};
275
276/**
277 * Strip content-* header fields.
278 *
279 * @api private
280 */
281
282SendStream.prototype.removeContentHeaderFields = function(){
283 var res = this.res;
284 Object.keys(res._headers).forEach(function(field){
285 if (0 == field.indexOf('content')) {
286 res.removeHeader(field);
287 }
288 });
289};
290
291/**
292 * Respond with 304 not modified.
293 *
294 * @api private
295 */
296
297SendStream.prototype.notModified = function(){
298 var res = this.res;
299 debug('not modified');
300 this.removeContentHeaderFields();
301 res.statusCode = 304;
302 res.end();
303};
304
305/**
306 * Raise error that headers already sent.
307 *
308 * @api private
309 */
310
311SendStream.prototype.headersAlreadySent = function headersAlreadySent(){
312 var err = new Error('Can\'t set headers after they are sent.');
313 debug('headers already sent');
314 this.error(500, err);
315};
316
317/**
318 * Check if the request is cacheable, aka
319 * responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}).
320 *
321 * @return {Boolean}
322 * @api private
323 */
324
325SendStream.prototype.isCachable = function(){
326 var res = this.res;
327 return (res.statusCode >= 200 && res.statusCode < 300) || 304 == res.statusCode;
328};
329
330/**
331 * Handle stat() error.
332 *
333 * @param {Error} err
334 * @api private
335 */
336
337SendStream.prototype.onStatError = function(err){
338 var notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'];
339 if (~notfound.indexOf(err.code)) return this.error(404, err);
340 this.error(500, err);
341};
342
343/**
344 * Check if the cache is fresh.
345 *
346 * @return {Boolean}
347 * @api private
348 */
349
350SendStream.prototype.isFresh = function(){
351 return fresh(this.req.headers, this.res._headers);
352};
353
354/**
355 * Check if the range is fresh.
356 *
357 * @return {Boolean}
358 * @api private
359 */
360
361SendStream.prototype.isRangeFresh = function isRangeFresh(){
362 var ifRange = this.req.headers['if-range'];
363
364 if (!ifRange) return true;
365
366 return ~ifRange.indexOf('"')
367 ? ~ifRange.indexOf(this.res._headers['etag'])
368 : Date.parse(this.res._headers['last-modified']) <= Date.parse(ifRange);
369};
370
371/**
372 * Redirect to `path`.
373 *
374 * @param {String} path
375 * @api private
376 */
377
378SendStream.prototype.redirect = function(path){
379 if (listenerCount(this, 'directory') !== 0) {
380 return this.emit('directory');
381 }
382
383 if (this.hasTrailingSlash()) return this.error(403);
384 var res = this.res;
385 path += '/';
386 res.statusCode = 301;
387 res.setHeader('Content-Type', 'text/html; charset=utf-8');
388 res.setHeader('Location', path);
389 res.end('Redirecting to <a href="' + escapeHtml(path) + '">' + escapeHtml(path) + '</a>\n');
390};
391
392/**
393 * Pipe to `res.
394 *
395 * @param {Stream} res
396 * @return {Stream} res
397 * @api public
398 */
399
400SendStream.prototype.pipe = function(res){
401 var self = this
402 , args = arguments
403 , path = this.path
404 , root = this._root;
405
406 // references
407 this.res = res;
408
409 // invalid request uri
410 path = utils.decode(path);
411 if (-1 == path) return this.error(400);
412
413 // null byte(s)
414 if (~path.indexOf('\0')) return this.error(400);
415
416 // join / normalize from optional root dir
417 if (root) path = normalize(join(this._root, path));
418
419 // ".." is malicious without "root"
420 if (this.isMalicious()) return this.error(403);
421
422 // malicious path
423 if (root && 0 != path.indexOf(root)) return this.error(403);
424
425 // hidden file support
426 if (!this._hidden && this.hasLeadingDot()) return this.error(404);
427
428 // index file support
429 if (this._index.length && this.hasTrailingSlash()) {
430 this.sendIndex(path);
431 return res;
432 }
433
434 debug('stat "%s"', path);
435 fs.stat(path, function(err, stat){
436 if (err) return self.onStatError(err);
437 if (stat.isDirectory()) return self.redirect(self.path);
438 self.emit('file', path, stat);
439 self.send(path, stat);
440 });
441
442 return res;
443};
444
445/**
446 * Transfer `path`.
447 *
448 * @param {String} path
449 * @api public
450 */
451
452SendStream.prototype.send = function(path, stat){
453 var options = this.options;
454 var len = stat.size;
455 var res = this.res;
456 var req = this.req;
457 var ranges = req.headers.range;
458 var offset = options.start || 0;
459
460 if (res._header) {
461 // impossible to send now
462 return this.headersAlreadySent();
463 }
464
465 debug('options %o', options);
466
467 // set header fields
468 this.setHeader(path, stat);
469
470 // set content-type
471 this.type(path);
472
473 // conditional GET support
474 if (this.isConditionalGET()
475 && this.isCachable()
476 && this.isFresh()) {
477 return this.notModified();
478 }
479
480 // adjust len to start/end options
481 len = Math.max(0, len - offset);
482 if (options.end !== undefined) {
483 var bytes = options.end - offset + 1;
484 if (len > bytes) len = bytes;
485 }
486
487 // Range support
488 if (ranges) {
489 ranges = parseRange(len, ranges);
490
491 // If-Range support
492 if (!this.isRangeFresh()) {
493 debug('range stale');
494 ranges = -2;
495 }
496
497 // unsatisfiable
498 if (-1 == ranges) {
499 debug('range unsatisfiable');
500 res.setHeader('Content-Range', 'bytes */' + stat.size);
501 return this.error(416);
502 }
503
504 // valid (syntactically invalid/multiple ranges are treated as a regular response)
505 if (-2 != ranges && ranges.length === 1) {
506 debug('range %j', ranges);
507
508 options.start = offset + ranges[0].start;
509 options.end = offset + ranges[0].end;
510
511 // Content-Range
512 res.statusCode = 206;
513 res.setHeader('Content-Range', 'bytes '
514 + ranges[0].start
515 + '-'
516 + ranges[0].end
517 + '/'
518 + len);
519 len = options.end - options.start + 1;
520 }
521 }
522
523 // content-length
524 res.setHeader('Content-Length', len);
525
526 // HEAD support
527 if ('HEAD' == req.method) return res.end();
528
529 this.stream(path, options);
530};
531
532/**
533 * Transfer index for `path`.
534 *
535 * @param {String} path
536 * @api private
537 */
538SendStream.prototype.sendIndex = function sendIndex(path){
539 var i = -1;
540 var self = this;
541
542 function next(err){
543 if (++i >= self._index.length) {
544 if (err) return self.onStatError(err);
545 return self.error(404);
546 }
547
548 var p = path + self._index[i];
549
550 debug('stat "%s"', p);
551 fs.stat(p, function(err, stat){
552 if (err) return next(err);
553 if (stat.isDirectory()) return next();
554 self.emit('file', p, stat);
555 self.send(p, stat);
556 });
557 }
558
559 if (!this.hasTrailingSlash()) path += '/';
560
561 next();
562};
563
564/**
565 * Stream `path` to the response.
566 *
567 * @param {String} path
568 * @param {Object} options
569 * @api private
570 */
571
572SendStream.prototype.stream = function(path, options){
573 // TODO: this is all lame, refactor meeee
574 var finished = false;
575 var self = this;
576 var res = this.res;
577 var req = this.req;
578
579 // pipe
580 var stream = fs.createReadStream(path, options);
581 this.emit('stream', stream);
582 stream.pipe(res);
583
584 // response finished, done with the fd
585 onFinished(res, function onfinished(){
586 finished = true;
587 stream.destroy();
588 });
589
590 // error handling code-smell
591 stream.on('error', function onerror(err){
592 // request already finished
593 if (finished) return;
594
595 // clean up stream
596 finished = true;
597 stream.destroy();
598
599 // no hope in responding
600 if (res._header) {
601 console.error(err.stack);
602 req.destroy();
603 return;
604 }
605
606 // error
607 self.onStatError(err);
608 });
609
610 // end
611 stream.on('end', function onend(){
612 self.emit('end');
613 });
614};
615
616/**
617 * Set content-type based on `path`
618 * if it hasn't been explicitly set.
619 *
620 * @param {String} path
621 * @api private
622 */
623
624SendStream.prototype.type = function(path){
625 var res = this.res;
626 if (res.getHeader('Content-Type')) return;
627 var type = mime.lookup(path);
628 var charset = mime.charsets.lookup(type);
629 debug('content-type %s', type);
630 res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : ''));
631};
632
633/**
634 * Set response header fields, most
635 * fields may be pre-defined.
636 *
637 * @param {String} path
638 * @param {Object} stat
639 * @api private
640 */
641
642SendStream.prototype.setHeader = function setHeader(path, stat){
643 var res = this.res;
644
645 this.emit('headers', res, path, stat);
646
647 if (!res.getHeader('Accept-Ranges')) res.setHeader('Accept-Ranges', 'bytes');
648 if (!res.getHeader('Date')) res.setHeader('Date', new Date().toUTCString());
649 if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', 'public, max-age=' + Math.floor(this._maxage / 1000));
650 if (!res.getHeader('Last-Modified')) res.setHeader('Last-Modified', stat.mtime.toUTCString());
651
652 if (this._etag && !res.getHeader('ETag')) {
653 var etag = utils.etag(path, stat);
654 debug('etag %s', etag);
655 res.setHeader('ETag', etag);
656 }
657};
658
659/**
660 * Normalize the index option into an array.
661 *
662 * @param {boolean|string|array} val
663 * @api private
664 */
665
666function normalizeIndex(val){
667 return [].concat(val || [])
668}