3 * Copyright(c) 2011 Sencha Inc.
4 * Copyright(c) 2011 TJ Holowaychuk
5 * Copyright(c) 2014 Douglas Christopher Wilson
9 // TODO: arrow key navigation
10 // TODO: make icons extensible
13 * Module dependencies.
16 var accepts = require('accepts');
17 var createError = require('http-errors');
18 var debug = require('debug')('serve-index');
19 var fs = require('fs')
20 , path = require('path')
21 , normalize = path.normalize
23 , extname = path.extname
25 var Batch = require('batch');
26 var mime = require('mime-types');
27 var parseUrl = require('parseurl');
28 var resolve = require('path').resolve;
40 var defaultTemplate = join(__dirname, 'public', 'directory.html');
46 var defaultStylesheet = join(__dirname, 'public', 'style.css');
49 * Media types and the map for content negotiation.
60 'text/plain': 'plain',
61 'application/json': 'json'
65 * Serve directory listings with the given `root` path.
67 * See Readme.md for documentation of options.
69 * @param {String} path
70 * @param {Object} options
71 * @return {Function} middleware
75 exports = module.exports = function serveIndex(root, options){
76 options = options || {};
79 if (!root) throw new TypeError('serveIndex() root path required');
81 // resolve root to absolute and normalize
83 root = normalize(root + sep);
85 var hidden = options.hidden
86 , icons = options.icons
87 , view = options.view || 'tiles'
88 , filter = options.filter
89 , template = options.template || defaultTemplate
90 , stylesheet = options.stylesheet || defaultStylesheet;
92 return function serveIndex(req, res, next) {
93 if (req.method !== 'GET' && req.method !== 'HEAD') {
94 res.statusCode = 'OPTIONS' === req.method
97 res.setHeader('Allow', 'GET, HEAD, OPTIONS');
103 var url = parseUrl(req);
104 var originalUrl = parseUrl.original(req);
105 var dir = decodeURIComponent(url.pathname);
106 var originalDir = decodeURIComponent(originalUrl.pathname);
108 // join / normalize from root dir
109 var path = normalize(join(root, dir));
111 // null byte(s), bad request
112 if (~path.indexOf('\0')) return next(createError(400));
115 if ((path + sep).substr(0, root.length) !== root) {
116 debug('malicious path "%s"', path);
117 return next(createError(403));
120 // determine ".." display
121 var showUp = normalize(resolve(path) + sep) !== root;
123 // check if we have a directory
124 debug('stat "%s"', path);
125 fs.stat(path, function(err, stat){
126 if (err && err.code === 'ENOENT') {
131 err.status = err.code === 'ENAMETOOLONG'
137 if (!stat.isDirectory()) return next();
140 debug('readdir "%s"', path);
141 fs.readdir(path, function(err, files){
142 if (err) return next(err);
143 if (!hidden) files = removeHidden(files);
144 if (filter) files = files.filter(function(filename, index, list) {
145 return filter(filename, index, list, path);
149 // content-negotiation
150 var accept = accepts(req);
151 var type = accept.type(mediaTypes);
154 if (!type) return next(createError(406));
155 exports[mediaType[type]](req, res, files, next, originalDir, showUp, icons, path, view, template, stylesheet);
162 * Respond with text/html.
165 exports.html = function(req, res, files, next, dir, showUp, icons, path, view, template, stylesheet){
166 fs.readFile(template, 'utf8', function(err, str){
167 if (err) return next(err);
168 fs.readFile(stylesheet, 'utf8', function(err, style){
169 if (err) return next(err);
170 stat(path, files, function(err, stats){
171 if (err) return next(err);
172 files = files.map(function(file, i){ return { name: file, stat: stats[i] }; });
173 files.sort(fileSort);
174 if (showUp) files.unshift({ name: '..' });
176 .replace(/\{style\}/g, style.concat(iconStyle(files, icons)))
177 .replace(/\{files\}/g, html(files, dir, icons, view))
178 .replace(/\{directory\}/g, dir)
179 .replace(/\{linked-path\}/g, htmlPath(dir));
181 var buf = new Buffer(str, 'utf8');
182 res.setHeader('Content-Type', 'text/html; charset=utf-8');
183 res.setHeader('Content-Length', buf.length);
191 * Respond with application/json.
194 exports.json = function(req, res, files){
195 var body = JSON.stringify(files);
196 var buf = new Buffer(body, 'utf8');
198 res.setHeader('Content-Type', 'application/json; charset=utf-8');
199 res.setHeader('Content-Length', buf.length);
204 * Respond with text/plain.
207 exports.plain = function(req, res, files){
208 var body = files.join('\n') + '\n';
209 var buf = new Buffer(body, 'utf8');
211 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
212 res.setHeader('Content-Length', buf.length);
217 * Sort function for with directories first.
220 function fileSort(a, b) {
221 return Number(b.stat && b.stat.isDirectory()) - Number(a.stat && a.stat.isDirectory()) ||
222 String(a.name).toLocaleLowerCase().localeCompare(String(b.name).toLocaleLowerCase());
226 * Map html `dir`, returning a linked path.
229 function htmlPath(dir) {
231 return dir.split('/').map(function(part){
232 curr.push(encodeURIComponent(part));
233 return part ? '<a href="' + curr.join('/') + '">' + part + '</a>' : '';
238 * Get the icon data for the file name.
241 function iconLookup(filename) {
242 var ext = extname(filename);
247 className: 'icon-' + ext.substring(1),
252 var mimetype = mime.lookup(ext);
254 // default if no mime type
255 if (mimetype === false) {
257 className: 'icon-default',
258 fileName: icons.default
263 if (icons[mimetype]) {
265 className: 'icon-' + mimetype.replace('/', '-'),
266 fileName: icons[mimetype]
270 var suffix = mimetype.split('+')[1];
272 if (suffix && icons['+' + suffix]) {
274 className: 'icon-' + suffix,
275 fileName: icons['+' + suffix]
279 var type = mimetype.split('/')[0];
284 className: 'icon-' + type,
285 fileName: icons[type]
290 className: 'icon-default',
291 fileName: icons.default
296 * Load icon images, return css string.
299 function iconStyle (files, useIcons) {
300 if (!useIcons) return '';
310 for (i = 0; i < files.length; i++) {
313 var isDir = '..' == file.name || (file.stat && file.stat.isDirectory());
315 ? { className: 'icon-directory', fileName: icons.folder }
316 : iconLookup(file.name);
317 var iconName = icon.fileName;
319 selector = '#files .' + icon.className + ' .name';
321 if (!rules[iconName]) {
322 rules[iconName] = 'background-image: url(data:image/png;base64,' + load(iconName) + ');'
323 selectors[iconName] = [];
327 if (selectors[iconName].indexOf(selector) === -1) {
328 selectors[iconName].push(selector);
332 for (i = 0; i < list.length; i++) {
334 style += selectors[iconName].join(',\n') + ' {\n ' + rules[iconName] + '\n}\n';
341 * Map html `files`, returning an html unordered list.
344 function html(files, dir, useIcons, view) {
345 return '<ul id="files" class="view-' + view + '">'
346 + (view == 'details' ? (
347 '<li class="header">'
348 + '<span class="name">Name</span>'
349 + '<span class="size">Size</span>'
350 + '<span class="date">Modified</span>'
352 + files.map(function(file){
353 var isDir = '..' == file.name || (file.stat && file.stat.isDirectory())
355 , path = dir.split('/').map(function (c) { return encodeURIComponent(c); });
358 classes.push('icon');
361 classes.push('icon-directory');
363 var ext = extname(file.name);
364 var icon = iconLookup(file.name);
366 classes.push('icon');
367 classes.push('icon-' + ext.substring(1));
369 if (classes.indexOf(icon.className) === -1) {
370 classes.push(icon.className);
375 path.push(encodeURIComponent(file.name));
377 var date = file.stat && file.name !== '..'
378 ? file.stat.mtime.toDateString() + ' ' + file.stat.mtime.toLocaleTimeString()
380 var size = file.stat && !isDir
384 return '<li><a href="'
385 + normalizeSlashes(normalize(path.join('/')))
387 + classes.join(' ') + '"'
388 + ' title="' + file.name + '">'
389 + '<span class="name">'+file.name+'</span>'
390 + '<span class="size">'+size+'</span>'
391 + '<span class="date">'+date+'</span>'
394 }).join('\n') + '</ul>';
398 * Load and cache the given `icon`.
400 * @param {String} icon
405 function load(icon) {
406 if (cache[icon]) return cache[icon];
407 return cache[icon] = fs.readFileSync(__dirname + '/public/icons/' + icon, 'base64');
411 * Normalizes the path separator from system separator
412 * to URL separator, aka `/`.
414 * @param {String} path
419 function normalizeSlashes(path) {
420 return path.split(sep).join('/');
424 * Filter "hidden" `files`, aka files
425 * beginning with a `.`.
427 * @param {Array} files
432 function removeHidden(files) {
433 return files.filter(function(file){
434 return '.' != file[0];
439 * Stat all files and return array of stat
443 function stat(dir, files, cb) {
444 var batch = new Batch();
446 batch.concurrency(10);
448 files.forEach(function(file){
449 batch.push(function(done){
450 fs.stat(join(dir, file), function(err, stat){
451 if (err && err.code !== 'ENOENT') return done(err);
453 // pass ENOENT as null stat, not error
454 done(null, stat || null);
468 'default': 'page_white.png',
469 'folder': 'folder.png',
471 // generic mime type icons
472 'image': 'image.png',
473 'text': 'page_white_text.png',
476 // generic mime suffix icons
477 '+json': 'page_white_code.png',
478 '+xml': 'page_white_code.png',
481 // specific mime type icons
482 'application/font-woff': 'font.png',
483 'application/javascript': 'page_white_code_red.png',
484 'application/json': 'page_white_code.png',
485 'application/msword': 'page_white_word.png',
486 'application/pdf': 'page_white_acrobat.png',
487 'application/postscript': 'page_white_vector.png',
488 'application/rtf': 'page_white_word.png',
489 'application/vnd.ms-excel': 'page_white_excel.png',
490 'application/vnd.ms-powerpoint': 'page_white_powerpoint.png',
491 'application/vnd.oasis.opendocument.presentation': 'page_white_powerpoint.png',
492 'application/vnd.oasis.opendocument.spreadsheet': 'page_white_excel.png',
493 'application/vnd.oasis.opendocument.text': 'page_white_word.png',
494 'application/x-7z-compressed': 'box.png',
495 'application/x-sh': 'application_xp_terminal.png',
496 'application/x-font-ttf': 'font.png',
497 'application/x-msaccess': 'page_white_database.png',
498 'application/x-shockwave-flash': 'page_white_flash.png',
499 'application/x-sql': 'page_white_database.png',
500 'application/x-tar': 'box.png',
501 'application/x-xz': 'box.png',
502 'application/xml': 'page_white_code.png',
503 'application/zip': 'box.png',
504 'image/svg+xml': 'page_white_vector.png',
505 'text/css': 'page_white_code.png',
506 'text/html': 'page_white_code.png',
507 'text/less': 'page_white_code.png',
509 // other, extension-specific icons
510 '.accdb': 'page_white_database.png',
512 '.app': 'application_xp.png',
513 '.as': 'page_white_actionscript.png',
514 '.asp': 'page_white_code.png',
515 '.aspx': 'page_white_code.png',
516 '.bat': 'application_xp_terminal.png',
518 '.c': 'page_white_c.png',
520 '.cfm': 'page_white_coldfusion.png',
521 '.clj': 'page_white_code.png',
522 '.cc': 'page_white_cplusplus.png',
523 '.cgi': 'application_xp_terminal.png',
524 '.cpp': 'page_white_cplusplus.png',
525 '.cs': 'page_white_csharp.png',
526 '.db': 'page_white_database.png',
527 '.dbf': 'page_white_database.png',
529 '.dll': 'page_white_gear.png',
531 '.docx': 'page_white_word.png',
532 '.erb': 'page_white_ruby.png',
533 '.exe': 'application_xp.png',
535 '.gam': 'controller.png',
537 '.h': 'page_white_h.png',
538 '.ini': 'page_white_gear.png',
541 '.java': 'page_white_cup.png',
542 '.jsp': 'page_white_cup.png',
543 '.lua': 'page_white_code.png',
546 '.m': 'page_white_code.png',
551 '.pdb': 'page_white_database.png',
552 '.php': 'page_white_php.png',
553 '.pl': 'page_white_code.png',
555 '.pptx': 'page_white_powerpoint.png',
556 '.psd': 'page_white_picture.png',
557 '.py': 'page_white_code.png',
559 '.rb': 'page_white_ruby.png',
561 '.rom': 'controller.png',
563 '.sass': 'page_white_code.png',
564 '.sav': 'controller.png',
565 '.scss': 'page_white_code.png',
566 '.srt': 'page_white_text.png',
570 '.vb': 'page_white_code.png',
571 '.vbs': 'page_white_code.png',
572 '.xcf': 'page_white_picture.png',
573 '.xlsx': 'page_white_excel.png',
574 '.yaws': 'page_white_code.png'