diff --git a/README.md b/README.md index 4167926..f4e5be0 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ Provide a max-age in milliseconds for http caching, defaults to 0. This can also be a string accepted by the [ms](https://www.npmjs.org/package/ms#readme) module. +##### redirectSymlinks + +When `true`, return symlinks as `301 Redirect` instead of the file they are +pointing. Default is `false`. + ##### root Serve files relative to `path`. @@ -229,7 +234,7 @@ var app = http.createServer(function onRequest (req, res) { }).listen(3000) ``` -## License +## License [MIT](LICENSE) diff --git a/index.js b/index.js index 81ec0b3..c7a007e 100644 --- a/index.js +++ b/index.js @@ -16,7 +16,6 @@ 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 EventEmitter = require('events').EventEmitter @@ -31,6 +30,10 @@ var statuses = require('statuses') var Stream = require('stream') var util = require('util') +var dirname = path.dirname +var relative = path.relative +var url = require('url') + /** * Path function references. * @private @@ -79,6 +82,31 @@ module.exports.mime = mime var listenerCount = EventEmitter.listenerCount || function (emitter, type) { return emitter.listeners(type).length } +function responseStatus (res, code, msg) { + var errMsg = msg + if (errMsg == null) { + errMsg = statuses[code] + + res.setHeader('Content-Type', 'text/plain; charset=UTF-8') + } + + res.statusCode = code + res.setHeader('Content-Length', Buffer.byteLength(msg)) + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(errMsg) +} + +function redirect (res, loc) { + // redirect + res.setHeader('Content-Type', 'text/html; charset=UTF-8') + res.setHeader('Location', loc) + + var escLoc = escapeHtml(loc) + var msg = 'Redirecting to ' + escLoc + '\n' + + responseStatus(res, 301, msg) +} + /** * Return a `SendStream` for `req` and `path`. * @@ -166,6 +194,8 @@ function SendStream (req, path, options) { ? resolve(opts.root) : null + this._redirectSymlinks = opts.redirectSymlinks + if (!this._root && opts.from) { this.from(opts.from) } @@ -278,7 +308,6 @@ SendStream.prototype.error = function error (status, error) { } var res = this.res - var msg = statuses[status] // clear existing headers clearHeaders(res) @@ -289,11 +318,7 @@ SendStream.prototype.error = function error (status, error) { } // 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) + responseStatus(res, status) } /** @@ -434,7 +459,7 @@ SendStream.prototype.isRangeFresh = function isRangeFresh () { * @private */ -SendStream.prototype.redirect = function redirect (path) { +SendStream.prototype.redirect = function redirectDirectory (path) { if (listenerCount(this, 'directory') !== 0) { this.emit('directory') return @@ -445,17 +470,38 @@ SendStream.prototype.redirect = function redirect (path) { return } - var loc = encodeUrl(collapseLeadingSlashes(path + '/')) - var msg = 'Redirecting to ' + escapeHtml(loc) + '\n' - var res = this.res + redirect(this.res, path + '/') +} - // 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) +/** + * Redirect a symbolic link. + * + * @param {string} path + * @private + */ + +SendStream.prototype.redirectSymbolicLink = function redirectSymbolicLink (path) { + var self = this + + fs.readlink(path, function (err, linkString) { + if (err) return self.onStatError(err) + + // Get absolute path on the real filesystem of the destination + path = dirname(path) + var to = resolve(path, linkString) + + // Check destination is not out of files root + if (to.indexOf(self._root) !== 0) return this.error(403) + + // Get relative paths for all symlinks, also for absolute ones + linkString = relative(path, to) + + // Resolve the URL, and make it relative (is this necessary?) + linkString = url.resolve(self.path, linkString) + linkString = relative(dirname(self.path), linkString) + + redirect(self.res, linkString) + }) } /** @@ -666,22 +712,30 @@ SendStream.prototype.sendFile = function sendFile (path) { 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) { + fs.lstat(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) + + if (stat.isSymbolicLink() && self._redirectSymlinks) { + return self.redirectSymbolicLink(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) + if (err) return self.onStatError(err) + return self.error(404) } var p = path + '.' + self._extensions[i++] @@ -689,7 +743,9 @@ SendStream.prototype.sendFile = function sendFile (path) { 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) }) @@ -703,21 +759,23 @@ SendStream.prototype.sendFile = function sendFile (path) { * @api private */ SendStream.prototype.sendIndex = function sendIndex (path) { - var i = -1 + var i = 0 var self = this function next (err) { - if (++i >= self._index.length) { + if (self._index.length <= i) { if (err) return self.onStatError(err) return self.error(404) } - var p = join(path, self._index[i]) + 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) }) @@ -846,24 +904,6 @@ function clearHeaders (res) { res._headerNames = {} } -/** - * 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. * diff --git a/package.json b/package.json index 3a93e89..9d932f7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "debug": "~2.2.0", "depd": "~1.1.0", "destroy": "~1.0.4", - "encodeurl": "~1.0.1", "escape-html": "~1.0.3", "etag": "~1.7.0", "fresh": "0.3.0", diff --git a/test/send.js b/test/send.js index da101aa..bf34024 100644 --- a/test/send.js +++ b/test/send.js @@ -132,6 +132,54 @@ describe('send(file).pipe(res)', function () { .expect(200, '404 ENOENT', done) }) + it('should 301 if the directory exists', function (done) { + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, 'Redirecting to /pets/\n', done) + }) + + it('should not redirect on symbolic links', function (done) { + var destination = 'name.txt' + var path2 = path.join(fixtures, 'symlink') + + fs.symlink(destination, path2, function (error) { + if (error && error.code !== 'EEXIST') return done(error) + + request(app) + .get('/symlink') + .expect(200, 'tobi', function (error) { + fs.unlink(path2, function (error2) { + done(error || error2) + }) + }) + }) + }) + + it("should 301 if it's a symbolic link and we want to redirect", function (done) { + var app = http.createServer(function (req, res) { + send(req, req.url, {root: fixtures, redirectSymlinks: true}) + .on('error', function (err) { res.end(err.statusCode + ' ' + err.code) }) + .pipe(res) + }) + + var destination = 'name.txt' + var path2 = path.join(fixtures, 'symlink') + + fs.symlink(destination, path2, function (error) { + if (error && error.code !== 'EEXIST') return done(error) + + request(app) + .get('/symlink') + .expect('Location', destination) + .expect(301, 'Redirecting to ' + destination + '\n', function (error) { + fs.unlink(path2, function (error2) { + done(error || error2) + }) + }) + }) + }) + it('should not override content-type', function (done) { var app = http.createServer(function (req, res) { res.setHeader('Content-Type', 'application/x-custom')