/*! * serve-index * Copyright(c) 2011 Sencha Inc. * Copyright(c) 2011 TJ Holowaychuk * Copyright(c) 2014 Douglas Christopher Wilson * MIT Licensed */ // TODO: arrow key navigation // TODO: make icons extensible /** * Module dependencies. */ var accepts = require('accepts'); var createError = require('http-errors'); var debug = require('debug')('serve-index'); var fs = require('fs') , path = require('path') , normalize = path.normalize , sep = path.sep , extname = path.extname , join = path.join; var Batch = require('batch'); var mime = require('mime-types'); var parseUrl = require('parseurl'); var resolve = require('path').resolve; /*! * Icon cache. */ var cache = {}; /*! * Default template. */ var defaultTemplate = join(__dirname, 'public', 'directory.html'); /*! * Stylesheet. */ var defaultStylesheet = join(__dirname, 'public', 'style.css'); /** * Media types and the map for content negotiation. */ var mediaTypes = [ 'text/html', 'text/plain', 'application/json' ]; var mediaType = { 'text/html': 'html', 'text/plain': 'plain', 'application/json': 'json' }; /** * Serve directory listings with the given `root` path. * * See Readme.md for documentation of options. * * @param {String} path * @param {Object} options * @return {Function} middleware * @api public */ exports = module.exports = function serveIndex(root, options){ options = options || {}; // root required if (!root) throw new TypeError('serveIndex() root path required'); // resolve root to absolute and normalize root = resolve(root); root = normalize(root + sep); var hidden = options.hidden , icons = options.icons , view = options.view || 'tiles' , filter = options.filter , template = options.template || defaultTemplate , stylesheet = options.stylesheet || defaultStylesheet; return function serveIndex(req, res, next) { if (req.method !== 'GET' && req.method !== 'HEAD') { res.statusCode = 'OPTIONS' === req.method ? 200 : 405; res.setHeader('Allow', 'GET, HEAD, OPTIONS'); res.end(); return; } // parse URLs var url = parseUrl(req); var originalUrl = parseUrl.original(req); var dir = decodeURIComponent(url.pathname); var originalDir = decodeURIComponent(originalUrl.pathname); // join / normalize from root dir var path = normalize(join(root, dir)); // null byte(s), bad request if (~path.indexOf('\0')) return next(createError(400)); // malicious path if ((path + sep).substr(0, root.length) !== root) { debug('malicious path "%s"', path); return next(createError(403)); } // determine ".." display var showUp = normalize(resolve(path) + sep) !== root; // check if we have a directory debug('stat "%s"', path); fs.stat(path, function(err, stat){ if (err && err.code === 'ENOENT') { return next(); } if (err) { err.status = err.code === 'ENAMETOOLONG' ? 414 : 500; return next(err); } if (!stat.isDirectory()) return next(); // 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); }); files.sort(); // content-negotiation var accept = accepts(req); var type = accept.type(mediaTypes); // not acceptable if (!type) return next(createError(406)); exports[mediaType[type]](req, res, files, next, originalDir, showUp, icons, path, view, template, stylesheet); }); }); }; }; /** * Respond with text/html. */ exports.html = function(req, res, files, next, dir, showUp, icons, path, 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){ if (err) return next(err); files = files.map(function(file, i){ return { name: file, stat: stats[i] }; }); files.sort(fileSort); if (showUp) files.unshift({ name: '..' }); str = str .replace(/\{style\}/g, style.concat(iconStyle(files, icons))) .replace(/\{files\}/g, html(files, dir, icons, view)) .replace(/\{directory\}/g, dir) .replace(/\{linked-path\}/g, htmlPath(dir)); var buf = new Buffer(str, 'utf8'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Length', buf.length); res.end(buf); }); }); }); }; /** * Respond with application/json. */ exports.json = function(req, res, files){ var body = JSON.stringify(files); var buf = new Buffer(body, 'utf8'); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Length', buf.length); res.end(buf); }; /** * Respond with text/plain. */ exports.plain = function(req, res, files){ var body = files.join('\n') + '\n'; var buf = new Buffer(body, 'utf8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Length', buf.length); res.end(buf); }; /** * Sort function for 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()); } /** * Map html `dir`, returning a linked path. */ function htmlPath(dir) { var curr = []; return dir.split('/').map(function(part){ curr.push(encodeURIComponent(part)); return part ? '' + part + '' : ''; }).join(' / '); } /** * Get the icon data for the file name. */ function iconLookup(filename) { var ext = extname(filename); // try by extension if (icons[ext]) { return { className: 'icon-' + ext.substring(1), fileName: icons[ext] }; } var mimetype = mime.lookup(ext); // default if no mime type if (mimetype === false) { return { className: 'icon-default', fileName: icons.default }; } // try by mime type if (icons[mimetype]) { return { className: 'icon-' + mimetype.replace('/', '-'), fileName: icons[mimetype] }; } var suffix = mimetype.split('+')[1]; if (suffix && icons['+' + suffix]) { return { className: 'icon-' + suffix, fileName: icons['+' + suffix] }; } var type = mimetype.split('/')[0]; // try by type only if (icons[type]) { return { className: 'icon-' + type, fileName: icons[type] }; } return { className: 'icon-default', fileName: icons.default }; } /** * Load icon images, return css string. */ function iconStyle (files, useIcons) { if (!useIcons) return ''; var className; var i; var iconName; var list = []; var rules = {}; var selector; var selectors = {}; var style = ''; for (i = 0; i < files.length; i++) { var file = files[i]; var isDir = '..' == file.name || (file.stat && file.stat.isDirectory()); var icon = isDir ? { className: 'icon-directory', fileName: icons.folder } : iconLookup(file.name); var iconName = icon.fileName; selector = '#files .' + icon.className + ' .name'; if (!rules[iconName]) { rules[iconName] = 'background-image: url(data:image/png;base64,' + load(iconName) + ');' selectors[iconName] = []; list.push(iconName); } if (selectors[iconName].indexOf(selector) === -1) { selectors[iconName].push(selector); } } for (i = 0; i < list.length; i++) { iconName = list[i]; style += selectors[iconName].join(',\n') + ' {\n ' + rules[iconName] + '\n}\n'; } return style; } /** * Map html `files`, returning an html unordered list. */ function html(files, dir, useIcons, view) { return '