diff --git a/README.md b/README.md index 80dfb0d5..b1942863 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ with a `path` of `'public'` will look at `'public/some/dir'`. If you are using something like `express`, you can change the URL "base" with `app.use` (see the express example). +`path` can also be an array to serve an overlay. The first matched file will be used for size and modified date (see the example). + #### Options Serve index accepts these properties in the options object. @@ -111,6 +113,19 @@ app.use('/ftp', serveIndex('public/ftp', {'icons': true})) app.listen() ``` +### Serve indexes from multiple directories with express + +```js +var express = require('express') +var serveIndex = require('serve-index') + +var app = express() + +// Serve URLs like /ftp/thing as an overlay of public1/ftp/thing and public2/ftp/thing +app.use('/ftp', serveIndex(['public1/ftp', 'public2/ftp'], {'icons': true})) +app.listen() +``` + ## License [MIT](LICENSE). The [Silk](http://www.famfamfam.com/lab/icons/silk/) icons diff --git a/index.js b/index.js index abf81a45..6cfc2d18 100644 --- a/index.js +++ b/index.js @@ -68,21 +68,27 @@ var mediaType = { * * See Readme.md for documentation of options. * - * @param {String} path + * @param {String|Array} path * @param {Object} options * @return {Function} middleware * @api public */ -exports = module.exports = function serveIndex(root, options){ +exports = module.exports = function serveIndex(roots, options){ options = options || {}; // root required - if (!root) throw new TypeError('serveIndex() root path required'); + if (!roots || roots.length === 0) throw new TypeError('serveIndex() root path required'); + + // ensure array + if (typeof roots === 'string') { + roots = [roots]; + } - // resolve root to absolute and normalize - root = resolve(root); - root = normalize(root + sep); + // resolve roots to absolute and normalize + roots = roots.map(function(root){ + return normalize(resolve(root) + sep); + }); var hidden = options.hidden , icons = options.icons @@ -107,55 +113,93 @@ exports = module.exports = function serveIndex(root, options){ var dir = decodeURIComponent(url.pathname); var originalDir = decodeURIComponent(originalUrl.pathname); - // join / normalize from root dir - var path = normalize(join(root, dir)); + // determine ".." display + var showUp = dir !== '/'; - // null byte(s), bad request - if (~path.indexOf('\0')) return next(createError(400)); + // content-negotiation + var accept = accepts(req); + var type = accept.type(mediaTypes); - // malicious path - if ((path + sep).substr(0, root.length) !== root) { - debug('malicious path "%s"', path); - return next(createError(403)); - } + // not acceptable + if (!type) return next(createError(406)); - // determine ".." display - var showUp = normalize(resolve(path) + sep) !== root; + var batch = new Batch(); - // check if we have a directory - debug('stat "%s"', path); - fs.stat(path, function(err, stat){ - if (err && err.code === 'ENOENT') { - return next(); - } + batch.concurrency(10); - if (err) { - err.status = err.code === 'ENAMETOOLONG' - ? 414 - : 500; - return next(err); - } + // read all root dirs and files with batch + roots.forEach(function(root){ + batch.push(function(done){ - if (!stat.isDirectory()) return next(); + // join / normalize from root dir + var path = normalize(join(root, dir)); - // fetch files - debug('readdir "%s"', path); - fs.readdir(path, function(err, files){ - if (err) return next(err); - if (!hidden) files = removeHidden(files); - if (filter) files = files.filter(function(filename, index, list) { - return filter(filename, index, list, path); + // null byte(s), bad request + if (~path.indexOf('\0')) return done(createError(400)); + + // malicious path + if ((path + sep).substr(0, root.length) !== root) { + debug('malicious path "%s"', path); + return done(createError(403)); + } + + // check if we have a directory + debug('stat "%s"', path); + fs.stat(path, function(err, stat){ + if (err && err.code === 'ENOENT') { + return done(null, null); + } + + if (err) { + err.status = err.code === 'ENAMETOOLONG' + ? 414 + : 500; + return done(err); + } + + if (!stat.isDirectory()) return done(null, null); + + // fetch files + debug('readdir "%s"', path); + fs.readdir(path, function(err, files){ + if (err) return done(err); + + // return dir path and files + done(null, { path: path, files: files }); + }); + }); + }); + }); + + // process all files + batch.end(function(err, dirs){ + if (err) return next(err); + + var files = [], // all files + fileMap = {}; // remember already added files + dirs.forEach(function(dir){ + if (!dir) return; + dir.files.forEach(function(file){ + if (!fileMap[file]) { + fileMap[file] = true; // set flag to not add the file again + files.push({ name: file, path: dir.path }); + } }); - files.sort(); + }); - // content-negotiation - var accept = accepts(req); - var type = accept.type(mediaTypes); + // no dirs/files found + if (files.length === 0) { + return next(); + } - // not acceptable - if (!type) return next(createError(406)); - exports[mediaType[type]](req, res, files, next, originalDir, showUp, icons, path, view, template, stylesheet); + if (!hidden) files = removeHidden(files); + if (filter) files = files.filter(function(file, index, list) { + return filter(file.name, index, list, file.path); }); + + files.sort(fileSort); + + exports[mediaType[type]](req, res, next, originalDir, files, showUp, icons, view, template, stylesheet); }); }; }; @@ -164,15 +208,14 @@ exports = module.exports = function serveIndex(root, options){ * Respond with text/html. */ -exports.html = function(req, res, files, next, dir, showUp, icons, path, view, template, stylesheet){ +exports.html = function(req, res, next, dir, files, showUp, icons, view, template, stylesheet){ fs.readFile(template, 'utf8', function(err, str){ if (err) return next(err); fs.readFile(stylesheet, 'utf8', function(err, style){ if (err) return next(err); - stat(path, files, function(err, stats){ + stat(files, function(err, files){ if (err) return next(err); - files = files.map(function(file, i){ return { name: file, stat: stats[i] }; }); - files.sort(fileSort); + files.sort(fileStatSort); if (showUp) files.unshift({ name: '..' }); str = str .replace(/\{style\}/g, style.concat(iconStyle(files, icons))) @@ -193,8 +236,8 @@ exports.html = function(req, res, files, next, dir, showUp, icons, path, view, t * Respond with application/json. */ -exports.json = function(req, res, files){ - var body = JSON.stringify(files); +exports.json = function(req, res, next, url, files){ + var body = JSON.stringify(files.map(function(file){ return file.name; })); var buf = new Buffer(body, 'utf8'); res.setHeader('Content-Type', 'application/json; charset=utf-8'); @@ -206,8 +249,8 @@ exports.json = function(req, res, files){ * Respond with text/plain. */ -exports.plain = function(req, res, files){ - var body = files.join('\n') + '\n'; +exports.plain = function(req, res, next, url, files){ + var body = files.map(function(file){ return file.name; }).join('\n') + '\n'; var buf = new Buffer(body, 'utf8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); @@ -216,12 +259,19 @@ exports.plain = function(req, res, files){ }; /** - * Sort function for with directories first. + * Sort function to compare file names. + */ + +function fileSort(a, b){ + return String(a.name).toLocaleLowerCase().localeCompare(String(b.name).toLocaleLowerCase()); +} + +/** + * Sort function to compare file names with directories first. */ -function fileSort(a, b) { - return Number(b.stat && b.stat.isDirectory()) - Number(a.stat && a.stat.isDirectory()) || - String(a.name).toLocaleLowerCase().localeCompare(String(b.name).toLocaleLowerCase()); +function fileStatSort(a, b){ + return Number(b.stat && b.stat.isDirectory()) - Number(a.stat && a.stat.isDirectory()) || fileSort(a, b); } /** @@ -440,32 +490,33 @@ function normalizeSlashes(path) { function removeHidden(files) { return files.filter(function(file){ - return '.' != file[0]; + return '.' != file.name[0]; }); } /** - * Stat all files and return array of stat - * in same order. + * Stat all files and add a `stat` property to each `files` object */ -function stat(dir, files, cb) { +function stat(files, cb) { var batch = new Batch(); batch.concurrency(10); files.forEach(function(file){ batch.push(function(done){ - fs.stat(join(dir, file), function(err, stat){ + fs.stat(join(file.path, file.name), function(err, stat){ if (err && err.code !== 'ENOENT') return done(err); - - // pass ENOENT as null stat, not error - done(null, stat || null); + if (stat) file.stat = stat; // add stat property to existing object + done(null, null); // don't pass a result as the passed object was enhanced }); }); }); - batch.end(cb); + batch.end(function(err){ + // ignore batch result, just pass back original array which was enhanced + cb(err, files); + }); } /** diff --git a/test/fixtures/#directory/index.html b/test/fixtures/public1/#directory/index.html similarity index 100% rename from test/fixtures/#directory/index.html rename to test/fixtures/public1/#directory/index.html diff --git a/test/fixtures/.hidden b/test/fixtures/public1/.hidden similarity index 100% rename from test/fixtures/.hidden rename to test/fixtures/public1/.hidden diff --git a/test/fixtures/collect/sample b/test/fixtures/public1/collect/sample similarity index 100% rename from test/fixtures/collect/sample rename to test/fixtures/public1/collect/sample diff --git a/test/fixtures/collect/sample.jpg b/test/fixtures/public1/collect/sample.jpg similarity index 100% rename from test/fixtures/collect/sample.jpg rename to test/fixtures/public1/collect/sample.jpg diff --git a/test/fixtures/collect/sample.mp4 b/test/fixtures/public1/collect/sample.mp4 similarity index 100% rename from test/fixtures/collect/sample.mp4 rename to test/fixtures/public1/collect/sample.mp4 diff --git a/test/fixtures/collect/sample.pdf b/test/fixtures/public1/collect/sample.pdf similarity index 100% rename from test/fixtures/collect/sample.pdf rename to test/fixtures/public1/collect/sample.pdf diff --git a/test/fixtures/collect/sample.qfx b/test/fixtures/public1/collect/sample.qfx similarity index 100% rename from test/fixtures/collect/sample.qfx rename to test/fixtures/public1/collect/sample.qfx diff --git a/test/fixtures/collect/sample.rdf b/test/fixtures/public1/collect/sample.rdf similarity index 100% rename from test/fixtures/collect/sample.rdf rename to test/fixtures/public1/collect/sample.rdf diff --git a/test/fixtures/collect/sample.txt b/test/fixtures/public1/collect/sample.txt similarity index 100% rename from test/fixtures/collect/sample.txt rename to test/fixtures/public1/collect/sample.txt diff --git a/test/fixtures/collect/sample.xlsx b/test/fixtures/public1/collect/sample.xlsx similarity index 100% rename from test/fixtures/collect/sample.xlsx rename to test/fixtures/public1/collect/sample.xlsx diff --git a/test/fixtures/foo & bar b/test/fixtures/public1/foo & bar similarity index 100% rename from test/fixtures/foo & bar rename to test/fixtures/public1/foo & bar diff --git a/test/fixtures/todo.txt b/test/fixtures/public1/todo.txt similarity index 100% rename from test/fixtures/todo.txt rename to test/fixtures/public1/todo.txt diff --git a/test/fixtures/users/index.html b/test/fixtures/public1/users/index.html similarity index 100% rename from test/fixtures/users/index.html rename to test/fixtures/public1/users/index.html diff --git a/test/fixtures/users/tobi.txt b/test/fixtures/public1/users/tobi.txt similarity index 100% rename from test/fixtures/users/tobi.txt rename to test/fixtures/public1/users/tobi.txt diff --git "a/test/fixtures/\343\201\225\343\201\217\343\202\211.txt" "b/test/fixtures/public1/\343\201\225\343\201\217\343\202\211.txt" similarity index 100% rename from "test/fixtures/\343\201\225\343\201\217\343\202\211.txt" rename to "test/fixtures/public1/\343\201\225\343\201\217\343\202\211.txt" diff --git a/test/fixtures/file #1.txt b/test/fixtures/public2/file #1.txt similarity index 100% rename from test/fixtures/file #1.txt rename to test/fixtures/public2/file #1.txt diff --git a/test/fixtures/g# %3 o & %2525 %37 dir/empty.txt b/test/fixtures/public2/g# %3 o & %2525 %37 dir/empty.txt similarity index 100% rename from test/fixtures/g# %3 o & %2525 %37 dir/empty.txt rename to test/fixtures/public2/g# %3 o & %2525 %37 dir/empty.txt diff --git a/test/fixtures/nums b/test/fixtures/public2/nums similarity index 100% rename from test/fixtures/nums rename to test/fixtures/public2/nums diff --git a/test/fixtures/public2/todo.txt b/test/fixtures/public2/todo.txt new file mode 100644 index 00000000..8c3539d9 --- /dev/null +++ b/test/fixtures/public2/todo.txt @@ -0,0 +1 @@ +- groceries \ No newline at end of file diff --git a/test/fixtures/public2/users/index.html b/test/fixtures/public2/users/index.html new file mode 100644 index 00000000..00a2db41 --- /dev/null +++ b/test/fixtures/public2/users/index.html @@ -0,0 +1 @@ +

tobi, loki, jane

\ No newline at end of file diff --git a/test/fixtures/public2/users/tobi.txt b/test/fixtures/public2/users/tobi.txt new file mode 100644 index 00000000..9d9529d4 --- /dev/null +++ b/test/fixtures/public2/users/tobi.txt @@ -0,0 +1 @@ +ferret \ No newline at end of file diff --git a/test/test.js b/test/test.js index eec1d233..e2a0aa18 100644 --- a/test/test.js +++ b/test/test.js @@ -8,6 +8,9 @@ var request = require('supertest'); var serveIndex = require('..'); var fixtures = path.join(__dirname, '/fixtures'); +var public1 = path.join(fixtures, '/public1'); +var public2 = path.join(fixtures, '/public2'); +var roots = [ public1, public2 ]; var relative = path.relative(process.cwd(), fixtures); var skipRelative = ~relative.indexOf('..') || path.resolve(relative) === relative; @@ -17,6 +20,10 @@ describe('serveIndex(root)', function () { assert.throws(serveIndex, /root path required/) }) + it('should require root array with elements', function () { + assert.throws(function () { serveIndex([]) }, /root path required/) + }) + it('should serve text/html without Accept header', function (done) { var server = createServer() @@ -219,7 +226,7 @@ describe('serveIndex(root)', function () { }); it('should filter hidden files', function (done) { - var server = createServer('test/fixtures', {'hidden': false}) + var server = createServer(roots, {'hidden': false}) request(server) .get('/') @@ -228,7 +235,7 @@ describe('serveIndex(root)', function () { }); it('should not filter hidden files', function (done) { - var server = createServer('test/fixtures', {'hidden': true}) + var server = createServer(roots, {'hidden': true}) request(server) .get('/') @@ -239,7 +246,7 @@ describe('serveIndex(root)', function () { describe('with "filter" option', function () { it('should custom filter files', function (done) { var cb = after(2, done) - var server = createServer(fixtures, {'filter': filter}) + var server = createServer(roots, {'filter': filter}) function filter(name) { if (name.indexOf('foo') === -1) return true @@ -254,7 +261,7 @@ describe('serveIndex(root)', function () { }); it('should filter after hidden filter', function (done) { - var server = createServer(fixtures, {'filter': filter, 'hidden': false}) + var server = createServer(roots, {'filter': filter, 'hidden': false}) function filter(name) { if (name.indexOf('.') === 0) { @@ -271,10 +278,10 @@ describe('serveIndex(root)', function () { it('should filter directory paths', function (done) { var cb = after(3, done) - var server = createServer(fixtures, {'filter': filter}) + var server = createServer(roots, {'filter': filter}) function filter(name, index, list, dir) { - if (path.normalize(dir) === path.normalize(path.join(fixtures, '/users'))) { + if (path.normalize(dir) === path.normalize(path.join(public1, '/users'))) { cb() } return true @@ -288,7 +295,7 @@ describe('serveIndex(root)', function () { describe('with "icons" option', function () { it('should include icons for html', function (done) { - var server = createServer(fixtures, {'icons': true}) + var server = createServer(roots, {'icons': true}) request(server) .get('/collect') @@ -311,7 +318,7 @@ describe('serveIndex(root)', function () { it('should get called with Accept: text/html', function (done) { var server = createServer() - serveIndex.html = function (req, res, files) { + serveIndex.html = function (req, res, next) { res.setHeader('Content-Type', 'text/html'); res.end('called'); } @@ -325,8 +332,9 @@ describe('serveIndex(root)', function () { it('should get file list', function (done) { var server = createServer() - serveIndex.html = function (req, res, files) { + serveIndex.html = function (req, res, next, dir, files) { var text = files + .map(function(file){ return file.name; }) .filter(function (f) { return /\.txt$/.test(f) }) .sort() res.setHeader('Content-Type', 'text/html') @@ -342,7 +350,7 @@ describe('serveIndex(root)', function () { it('should get dir name', function (done) { var server = createServer() - serveIndex.html = function (req, res, files, next, dir) { + serveIndex.html = function (req, res, next, dir, files) { res.setHeader('Content-Type', 'text/html') res.end('' + dir + '') } @@ -356,7 +364,7 @@ describe('serveIndex(root)', function () { it('should get template path', function (done) { var server = createServer() - serveIndex.html = function (req, res, files, next, dir, showUp, icons, path, view, template) { + serveIndex.html = function (req, res, next, dir, files, showUp, icons, view, template) { res.setHeader('Content-Type', 'text/html') res.end(String(fs.existsSync(template))) } @@ -370,7 +378,7 @@ describe('serveIndex(root)', function () { it('should get template with tokens', function (done) { var server = createServer() - serveIndex.html = function (req, res, files, next, dir, showUp, icons, path, view, template) { + serveIndex.html = function (req, res, next, dir, files, showUp, icons, view, template) { res.setHeader('Content-Type', 'text/html') res.end(fs.readFileSync(template, 'utf8')) } @@ -388,7 +396,7 @@ describe('serveIndex(root)', function () { it('should get stylesheet path', function (done) { var server = createServer() - serveIndex.html = function (req, res, files, next, dir, showUp, icons, path, view, template, stylesheet) { + serveIndex.html = function (req, res, next, dir, files, showUp, icons, view, template, stylesheet) { res.setHeader('Content-Type', 'text/html') res.end(String(fs.existsSync(stylesheet))) } @@ -406,7 +414,7 @@ describe('serveIndex(root)', function () { it('should get called with Accept: text/plain', function (done) { var server = createServer() - serveIndex.plain = function (req, res, files) { + serveIndex.plain = function (req, res, next, dir, files) { res.setHeader('Content-Type', 'text/plain'); res.end('called'); } @@ -424,7 +432,7 @@ describe('serveIndex(root)', function () { it('should get called with Accept: application/json', function (done) { var server = createServer() - serveIndex.json = function (req, res, files) { + serveIndex.json = function (req, res, next, dir, files) { res.setHeader('Content-Type', 'application/json'); res.end('"called"'); } @@ -504,7 +512,7 @@ describe('serveIndex(root)', function () { describe('when setting a custom template', function () { var server; before(function () { - server = createServer(fixtures, {'template': __dirname + '/shared/template.html'}); + server = createServer(roots, {'template': __dirname + '/shared/template.html'}); }); it('should respond with file list', function (done) { @@ -548,7 +556,7 @@ describe('serveIndex(root)', function () { describe('when setting a custom stylesheet', function () { var server; before(function () { - server = createServer(fixtures, {'stylesheet': __dirname + '/shared/styles.css'}); + server = createServer(roots, {'stylesheet': __dirname + '/shared/styles.css'}); }); it('should respond with appropriate embedded styles', function (done) { @@ -565,7 +573,7 @@ describe('serveIndex(root)', function () { describe('when set with trailing slash', function () { var server; before(function () { - server = createServer(fixtures + '/'); + server = createServer([ public1 + '/', public2 + '/']); }); it('should respond with file list', function (done) { @@ -588,7 +596,7 @@ describe('serveIndex(root)', function () { }); it('should respond with file list', function (done) { - var dest = relative.split(path.sep).join('/'); + var dest = relative.split(path.sep).join('/') + '/public2'; request(server) .get('/' + dest + '/') .set('Accept', 'application/json') @@ -622,7 +630,7 @@ function alterProperty(obj, prop, val) { } function createServer(dir, opts) { - dir = dir || fixtures + dir = dir || roots var _serveIndex = serveIndex(dir, opts)