/** * Module dependencies. */ var debug = require('debug')('send') , parseRange = require('range-parser') , Stream = require('stream') , mime = require('mime') , fresh = require('fresh') , path = require('path') , http = require('http') , fs = require('fs') , basename = path.basename , normalize = path.normalize , join = path.join , utils = require('./utils'); /** * Expose `send`. */ exports = module.exports = send; /** * Expose mime module. */ exports.mime = mime; /** * Return a `SendStream` for `req` and `path`. * * @param {Request} req * @param {String} path * @param {Object} options * @return {SendStream} * @api public */ function send(req, path, options) { return new SendStream(req, path, options); } /** * Initialize a `SendStream` with the given `path`. * * Events: * * - `error` an error occurred * - `stream` file streaming has started * - `end` streaming has completed * - `directory` a directory was requested * * @param {Request} req * @param {String} path * @param {Object} options * @api private */ function SendStream(req, path, options) { var self = this; this.req = req; this.path = path; this.options = options || {}; this.maxage(0); this.hidden(false); this.index('index.html'); } /** * Inherits from `Stream.prototype`. */ SendStream.prototype.__proto__ = Stream.prototype; /** * Enable or disable "hidden" (dot) files. * * @param {Boolean} path * @return {SendStream} * @api public */ SendStream.prototype.hidden = function(val){ debug('hidden %s', val); this._hidden = val; return this; }; /** * Set index `path`, set to a falsy * value to disable index support. * * @param {String|Boolean} path * @return {SendStream} * @api public */ SendStream.prototype.index = function(path){ debug('index %s', path); this._index = path; return this; }; /** * Set root `path`. * * @param {String} path * @return {SendStream} * @api public */ SendStream.prototype.root = SendStream.prototype.from = function(path){ this._root = normalize(path); return this; }; /** * Set max-age to `ms`. * * @param {Number} ms * @return {SendStream} * @api public */ SendStream.prototype.maxage = function(ms){ if (Infinity == ms) ms = 60 * 60 * 24 * 365 * 1000; debug('max-age %d', ms); this._maxage = ms; return this; }; /** * Emit error with `status`. * * @param {Number} status * @api private */ SendStream.prototype.error = function(status, err){ var res = this.res; var msg = http.STATUS_CODES[status]; err = err || new Error(msg); err.status = status; if (this.listeners('error').length) return this.emit('error', err); res.statusCode = err.status; res.end(msg); }; /** * Check if the pathname is potentially malicious. * * @return {Boolean} * @api private */ SendStream.prototype.isMalicious = function(){ return !this._root && ~this.path.indexOf('..'); }; /** * Check if the pathname ends with "/". * * @return {Boolean} * @api private */ SendStream.prototype.hasTrailingSlash = function(){ return '/' == this.path[this.path.length - 1]; }; /** * Check if the basename leads with ".". * * @return {Boolean} * @api private */ SendStream.prototype.hasLeadingDot = function(){ return '.' == basename(this.path)[0]; }; /** * 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. * * @api private */ SendStream.prototype.removeContentHeaderFields = function(){ var res = this.res; Object.keys(res._headers).forEach(function(field){ if (0 == field.indexOf('content')) { res.removeHeader(field); } }); }; /** * 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(); }; /** * 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} err * @api private */ SendStream.prototype.onStatError = function(err){ var notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; if (~notfound.indexOf(err.code)) return this.error(404, err); this.error(500, err); }; /** * Check if the cache is fresh. * * @return {Boolean} * @api private */ SendStream.prototype.isFresh = function(){ return fresh(this.req.headers, this.res._headers); }; /** * Redirect to `path`. * * @param {String} path * @api private */ SendStream.prototype.redirect = function(path){ if (this.listeners('directory').length) return this.emit('directory'); var res = this.res; path += '/'; res.statusCode = 301; res.setHeader('Location', path); res.end('Redirecting to ' + utils.escape(path)); }; /** * Pipe to `res. * * @param {Stream} res * @return {Stream} res * @api public */ SendStream.prototype.pipe = function(res){ var self = this , args = arguments , path = this.path , root = this._root; // references this.res = res; // invalid request uri path = utils.decode(path); if (-1 == path) return this.error(400); // null byte(s) if (~path.indexOf('\0')) return this.error(400); // join / normalize from optional root dir if (root) path = normalize(join(this._root, path)); // ".." is malicious without "root" if (this.isMalicious()) return this.error(403); // malicious path if (root && 0 != path.indexOf(root)) return this.error(403); // hidden file support if (!this._hidden && this.hasLeadingDot()) return this.error(404); // index file support if (this._index && this.hasTrailingSlash()) path += this._index; debug('stat "%s"', path); fs.stat(path, function(err, stat){ if (err) return self.onStatError(err); if (stat.isDirectory()) return self.redirect(self.path); self.send(path, stat); }); return res; }; /** * Transfer `path`. * * @param {String} path * @api public */ SendStream.prototype.send = function(path, stat){ var options = this.options; var len = stat.size; var res = this.res; var req = this.req; var ranges = req.headers.range; var offset = options.start || 0; // set header fields this.setHeader(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); // unsatisfiable if (-1 == ranges) { res.setHeader('Content-Range', 'bytes */' + stat.size); return this.error(416); } // valid (syntactically invalid ranges are treated as a regular response) if (-2 != ranges) { options.start = offset + ranges[0].start; options.end = offset + ranges[0].end; // Content-Range res.statusCode = 206; res.setHeader('Content-Range', 'bytes ' + ranges[0].start + '-' + ranges[0].end + '/' + len); len = options.end - options.start + 1; } } // content-length res.setHeader('Content-Length', len); // HEAD support if ('HEAD' == req.method) return res.end(); this.stream(path, options); }; /** * 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 self = this; var res = this.res; var req = this.req; // pipe var stream = fs.createReadStream(path, options); this.emit('stream', stream); stream.pipe(res); // socket closed, done with the fd req.on('close', stream.destroy.bind(stream)); // error handling code-smell stream.on('error', function(err){ // no hope in responding if (res._header) { console.error(err.stack); req.destroy(); return; } // 500 err.status = 500; self.emit('error', err); }); // end stream.on('end', function(){ 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 reaponse header fields, most * fields may be pre-defined. * * @param {Object} stat * @api private */ SendStream.prototype.setHeader = function(stat){ var res = this.res; if (!res.getHeader('Accept-Ranges')) res.setHeader('Accept-Ranges', 'bytes'); if (!res.getHeader('ETag')) res.setHeader('ETag', utils.etag(stat)); if (!res.getHeader('Date')) res.setHeader('Date', new Date().toUTCString()); if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', 'public, max-age=' + (this._maxage / 1000)); if (!res.getHeader('Last-Modified')) res.setHeader('Last-Modified', stat.mtime.toUTCString()); };