You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

646 lines
15 KiB

4 years ago
  1. /*!
  2. * serve-index
  3. * Copyright(c) 2011 Sencha Inc.
  4. * Copyright(c) 2011 TJ Holowaychuk
  5. * Copyright(c) 2014-2015 Douglas Christopher Wilson
  6. * MIT Licensed
  7. */
  8. 'use strict';
  9. /**
  10. * Module dependencies.
  11. * @private
  12. */
  13. var accepts = require('accepts');
  14. var createError = require('http-errors');
  15. var debug = require('debug')('serve-index');
  16. var escapeHtml = require('escape-html');
  17. var fs = require('fs')
  18. , path = require('path')
  19. , normalize = path.normalize
  20. , sep = path.sep
  21. , extname = path.extname
  22. , join = path.join;
  23. var Batch = require('batch');
  24. var mime = require('mime-types');
  25. var parseUrl = require('parseurl');
  26. var resolve = require('path').resolve;
  27. /**
  28. * Module exports.
  29. * @public
  30. */
  31. module.exports = serveIndex;
  32. /*!
  33. * Icon cache.
  34. */
  35. var cache = {};
  36. /*!
  37. * Default template.
  38. */
  39. var defaultTemplate = join(__dirname, 'public', 'directory.html');
  40. /*!
  41. * Stylesheet.
  42. */
  43. var defaultStylesheet = join(__dirname, 'public', 'style.css');
  44. /**
  45. * Media types and the map for content negotiation.
  46. */
  47. var mediaTypes = [
  48. 'text/html',
  49. 'text/plain',
  50. 'application/json'
  51. ];
  52. var mediaType = {
  53. 'text/html': 'html',
  54. 'text/plain': 'plain',
  55. 'application/json': 'json'
  56. };
  57. /**
  58. * Serve directory listings with the given `root` path.
  59. *
  60. * See Readme.md for documentation of options.
  61. *
  62. * @param {String} root
  63. * @param {Object} options
  64. * @return {Function} middleware
  65. * @public
  66. */
  67. function serveIndex(root, options) {
  68. var opts = options || {};
  69. // root required
  70. if (!root) {
  71. throw new TypeError('serveIndex() root path required');
  72. }
  73. // resolve root to absolute and normalize
  74. var rootPath = normalize(resolve(root) + sep);
  75. var filter = opts.filter;
  76. var hidden = opts.hidden;
  77. var icons = opts.icons;
  78. var stylesheet = opts.stylesheet || defaultStylesheet;
  79. var template = opts.template || defaultTemplate;
  80. var view = opts.view || 'tiles';
  81. return function (req, res, next) {
  82. if (req.method !== 'GET' && req.method !== 'HEAD') {
  83. res.statusCode = 'OPTIONS' === req.method ? 200 : 405;
  84. res.setHeader('Allow', 'GET, HEAD, OPTIONS');
  85. res.setHeader('Content-Length', '0');
  86. res.end();
  87. return;
  88. }
  89. // parse URLs
  90. var url = parseUrl(req);
  91. var originalUrl = parseUrl.original(req);
  92. var dir = decodeURIComponent(url.pathname);
  93. var originalDir = decodeURIComponent(originalUrl.pathname);
  94. // join / normalize from root dir
  95. var path = normalize(join(rootPath, dir));
  96. // null byte(s), bad request
  97. if (~path.indexOf('\0')) return next(createError(400));
  98. // malicious path
  99. if ((path + sep).substr(0, rootPath.length) !== rootPath) {
  100. debug('malicious path "%s"', path);
  101. return next(createError(403));
  102. }
  103. // determine ".." display
  104. var showUp = normalize(resolve(path) + sep) !== rootPath;
  105. // check if we have a directory
  106. debug('stat "%s"', path);
  107. fs.stat(path, function(err, stat){
  108. if (err && err.code === 'ENOENT') {
  109. return next();
  110. }
  111. if (err) {
  112. err.status = err.code === 'ENAMETOOLONG'
  113. ? 414
  114. : 500;
  115. return next(err);
  116. }
  117. if (!stat.isDirectory()) return next();
  118. // fetch files
  119. debug('readdir "%s"', path);
  120. fs.readdir(path, function(err, files){
  121. if (err) return next(err);
  122. if (!hidden) files = removeHidden(files);
  123. if (filter) files = files.filter(function(filename, index, list) {
  124. return filter(filename, index, list, path);
  125. });
  126. files.sort();
  127. // content-negotiation
  128. var accept = accepts(req);
  129. var type = accept.type(mediaTypes);
  130. // not acceptable
  131. if (!type) return next(createError(406));
  132. serveIndex[mediaType[type]](req, res, files, next, originalDir, showUp, icons, path, view, template, stylesheet);
  133. });
  134. });
  135. };
  136. };
  137. /**
  138. * Respond with text/html.
  139. */
  140. serveIndex.html = function _html(req, res, files, next, dir, showUp, icons, path, view, template, stylesheet) {
  141. var render = typeof template !== 'function'
  142. ? createHtmlRender(template)
  143. : template
  144. if (showUp) {
  145. files.unshift('..');
  146. }
  147. // stat all files
  148. stat(path, files, function (err, stats) {
  149. if (err) return next(err);
  150. // combine the stats into the file list
  151. var fileList = files.map(function (file, i) {
  152. return { name: file, stat: stats[i] };
  153. });
  154. // sort file list
  155. fileList.sort(fileSort);
  156. // read stylesheet
  157. fs.readFile(stylesheet, 'utf8', function (err, style) {
  158. if (err) return next(err);
  159. // create locals for rendering
  160. var locals = {
  161. directory: dir,
  162. displayIcons: Boolean(icons),
  163. fileList: fileList,
  164. path: path,
  165. style: style,
  166. viewName: view
  167. };
  168. // render html
  169. render(locals, function (err, body) {
  170. if (err) return next(err);
  171. send(res, 'text/html', body)
  172. });
  173. });
  174. });
  175. };
  176. /**
  177. * Respond with application/json.
  178. */
  179. serveIndex.json = function _json(req, res, files) {
  180. send(res, 'application/json', JSON.stringify(files))
  181. };
  182. /**
  183. * Respond with text/plain.
  184. */
  185. serveIndex.plain = function _plain(req, res, files) {
  186. send(res, 'text/plain', (files.join('\n') + '\n'))
  187. };
  188. /**
  189. * Map html `files`, returning an html unordered list.
  190. * @private
  191. */
  192. function createHtmlFileList(files, dir, useIcons, view) {
  193. var html = '<ul id="files" class="view-' + escapeHtml(view) + '">'
  194. + (view == 'details' ? (
  195. '<li class="header">'
  196. + '<span class="name">Name</span>'
  197. + '<span class="size">Size</span>'
  198. + '<span class="date">Modified</span>'
  199. + '</li>') : '');
  200. html += files.map(function (file) {
  201. var classes = [];
  202. var isDir = file.stat && file.stat.isDirectory();
  203. var path = dir.split('/').map(function (c) { return encodeURIComponent(c); });
  204. if (useIcons) {
  205. classes.push('icon');
  206. if (isDir) {
  207. classes.push('icon-directory');
  208. } else {
  209. var ext = extname(file.name);
  210. var icon = iconLookup(file.name);
  211. classes.push('icon');
  212. classes.push('icon-' + ext.substring(1));
  213. if (classes.indexOf(icon.className) === -1) {
  214. classes.push(icon.className);
  215. }
  216. }
  217. }
  218. path.push(encodeURIComponent(file.name));
  219. var date = file.stat && file.name !== '..'
  220. ? file.stat.mtime.toLocaleDateString() + ' ' + file.stat.mtime.toLocaleTimeString()
  221. : '';
  222. var size = file.stat && !isDir
  223. ? file.stat.size
  224. : '';
  225. return '<li><a href="'
  226. + escapeHtml(normalizeSlashes(normalize(path.join('/'))))
  227. + '" class="' + escapeHtml(classes.join(' ')) + '"'
  228. + ' title="' + escapeHtml(file.name) + '">'
  229. + '<span class="name">' + escapeHtml(file.name) + '</span>'
  230. + '<span class="size">' + escapeHtml(size) + '</span>'
  231. + '<span class="date">' + escapeHtml(date) + '</span>'
  232. + '</a></li>';
  233. }).join('\n');
  234. html += '</ul>';
  235. return html;
  236. }
  237. /**
  238. * Create function to render html.
  239. */
  240. function createHtmlRender(template) {
  241. return function render(locals, callback) {
  242. // read template
  243. fs.readFile(template, 'utf8', function (err, str) {
  244. if (err) return callback(err);
  245. var body = str
  246. .replace(/\{style\}/g, locals.style.concat(iconStyle(locals.fileList, locals.displayIcons)))
  247. .replace(/\{files\}/g, createHtmlFileList(locals.fileList, locals.directory, locals.displayIcons, locals.viewName))
  248. .replace(/\{directory\}/g, escapeHtml(locals.directory))
  249. .replace(/\{linked-path\}/g, htmlPath(locals.directory));
  250. callback(null, body);
  251. });
  252. };
  253. }
  254. /**
  255. * Sort function for with directories first.
  256. */
  257. function fileSort(a, b) {
  258. // sort ".." to the top
  259. if (a.name === '..' || b.name === '..') {
  260. return a.name === b.name ? 0
  261. : a.name === '..' ? -1 : 1;
  262. }
  263. return Number(b.stat && b.stat.isDirectory()) - Number(a.stat && a.stat.isDirectory()) ||
  264. String(a.name).toLocaleLowerCase().localeCompare(String(b.name).toLocaleLowerCase());
  265. }
  266. /**
  267. * Map html `dir`, returning a linked path.
  268. */
  269. function htmlPath(dir) {
  270. var parts = dir.split('/');
  271. var crumb = new Array(parts.length);
  272. for (var i = 0; i < parts.length; i++) {
  273. var part = parts[i];
  274. if (part) {
  275. parts[i] = encodeURIComponent(part);
  276. crumb[i] = '<a href="' + escapeHtml(parts.slice(0, i + 1).join('/')) + '">' + escapeHtml(part) + '</a>';
  277. }
  278. }
  279. return crumb.join(' / ');
  280. }
  281. /**
  282. * Get the icon data for the file name.
  283. */
  284. function iconLookup(filename) {
  285. var ext = extname(filename);
  286. // try by extension
  287. if (icons[ext]) {
  288. return {
  289. className: 'icon-' + ext.substring(1),
  290. fileName: icons[ext]
  291. };
  292. }
  293. var mimetype = mime.lookup(ext);
  294. // default if no mime type
  295. if (mimetype === false) {
  296. return {
  297. className: 'icon-default',
  298. fileName: icons.default
  299. };
  300. }
  301. // try by mime type
  302. if (icons[mimetype]) {
  303. return {
  304. className: 'icon-' + mimetype.replace('/', '-'),
  305. fileName: icons[mimetype]
  306. };
  307. }
  308. var suffix = mimetype.split('+')[1];
  309. if (suffix && icons['+' + suffix]) {
  310. return {
  311. className: 'icon-' + suffix,
  312. fileName: icons['+' + suffix]
  313. };
  314. }
  315. var type = mimetype.split('/')[0];
  316. // try by type only
  317. if (icons[type]) {
  318. return {
  319. className: 'icon-' + type,
  320. fileName: icons[type]
  321. };
  322. }
  323. return {
  324. className: 'icon-default',
  325. fileName: icons.default
  326. };
  327. }
  328. /**
  329. * Load icon images, return css string.
  330. */
  331. function iconStyle(files, useIcons) {
  332. if (!useIcons) return '';
  333. var i;
  334. var list = [];
  335. var rules = {};
  336. var selector;
  337. var selectors = {};
  338. var style = '';
  339. for (i = 0; i < files.length; i++) {
  340. var file = files[i];
  341. var isDir = file.stat && file.stat.isDirectory();
  342. var icon = isDir
  343. ? { className: 'icon-directory', fileName: icons.folder }
  344. : iconLookup(file.name);
  345. var iconName = icon.fileName;
  346. selector = '#files .' + icon.className + ' .name';
  347. if (!rules[iconName]) {
  348. rules[iconName] = 'background-image: url(data:image/png;base64,' + load(iconName) + ');'
  349. selectors[iconName] = [];
  350. list.push(iconName);
  351. }
  352. if (selectors[iconName].indexOf(selector) === -1) {
  353. selectors[iconName].push(selector);
  354. }
  355. }
  356. for (i = 0; i < list.length; i++) {
  357. iconName = list[i];
  358. style += selectors[iconName].join(',\n') + ' {\n ' + rules[iconName] + '\n}\n';
  359. }
  360. return style;
  361. }
  362. /**
  363. * Load and cache the given `icon`.
  364. *
  365. * @param {String} icon
  366. * @return {String}
  367. * @api private
  368. */
  369. function load(icon) {
  370. if (cache[icon]) return cache[icon];
  371. return cache[icon] = fs.readFileSync(__dirname + '/public/icons/' + icon, 'base64');
  372. }
  373. /**
  374. * Normalizes the path separator from system separator
  375. * to URL separator, aka `/`.
  376. *
  377. * @param {String} path
  378. * @return {String}
  379. * @api private
  380. */
  381. function normalizeSlashes(path) {
  382. return path.split(sep).join('/');
  383. };
  384. /**
  385. * Filter "hidden" `files`, aka files
  386. * beginning with a `.`.
  387. *
  388. * @param {Array} files
  389. * @return {Array}
  390. * @api private
  391. */
  392. function removeHidden(files) {
  393. return files.filter(function(file){
  394. return '.' != file[0];
  395. });
  396. }
  397. /**
  398. * Send a response.
  399. * @private
  400. */
  401. function send (res, type, body) {
  402. // security header for content sniffing
  403. res.setHeader('X-Content-Type-Options', 'nosniff')
  404. // standard headers
  405. res.setHeader('Content-Type', type + '; charset=utf-8')
  406. res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'))
  407. // body
  408. res.end(body, 'utf8')
  409. }
  410. /**
  411. * Stat all files and return array of stat
  412. * in same order.
  413. */
  414. function stat(dir, files, cb) {
  415. var batch = new Batch();
  416. batch.concurrency(10);
  417. files.forEach(function(file){
  418. batch.push(function(done){
  419. fs.stat(join(dir, file), function(err, stat){
  420. if (err && err.code !== 'ENOENT') return done(err);
  421. // pass ENOENT as null stat, not error
  422. done(null, stat || null);
  423. });
  424. });
  425. });
  426. batch.end(cb);
  427. }
  428. /**
  429. * Icon map.
  430. */
  431. var icons = {
  432. // base icons
  433. 'default': 'page_white.png',
  434. 'folder': 'folder.png',
  435. // generic mime type icons
  436. 'image': 'image.png',
  437. 'text': 'page_white_text.png',
  438. 'video': 'film.png',
  439. // generic mime suffix icons
  440. '+json': 'page_white_code.png',
  441. '+xml': 'page_white_code.png',
  442. '+zip': 'box.png',
  443. // specific mime type icons
  444. 'application/font-woff': 'font.png',
  445. 'application/javascript': 'page_white_code_red.png',
  446. 'application/json': 'page_white_code.png',
  447. 'application/msword': 'page_white_word.png',
  448. 'application/pdf': 'page_white_acrobat.png',
  449. 'application/postscript': 'page_white_vector.png',
  450. 'application/rtf': 'page_white_word.png',
  451. 'application/vnd.ms-excel': 'page_white_excel.png',
  452. 'application/vnd.ms-powerpoint': 'page_white_powerpoint.png',
  453. 'application/vnd.oasis.opendocument.presentation': 'page_white_powerpoint.png',
  454. 'application/vnd.oasis.opendocument.spreadsheet': 'page_white_excel.png',
  455. 'application/vnd.oasis.opendocument.text': 'page_white_word.png',
  456. 'application/x-7z-compressed': 'box.png',
  457. 'application/x-sh': 'application_xp_terminal.png',
  458. 'application/x-font-ttf': 'font.png',
  459. 'application/x-msaccess': 'page_white_database.png',
  460. 'application/x-shockwave-flash': 'page_white_flash.png',
  461. 'application/x-sql': 'page_white_database.png',
  462. 'application/x-tar': 'box.png',
  463. 'application/x-xz': 'box.png',
  464. 'application/xml': 'page_white_code.png',
  465. 'application/zip': 'box.png',
  466. 'image/svg+xml': 'page_white_vector.png',
  467. 'text/css': 'page_white_code.png',
  468. 'text/html': 'page_white_code.png',
  469. 'text/less': 'page_white_code.png',
  470. // other, extension-specific icons
  471. '.accdb': 'page_white_database.png',
  472. '.apk': 'box.png',
  473. '.app': 'application_xp.png',
  474. '.as': 'page_white_actionscript.png',
  475. '.asp': 'page_white_code.png',
  476. '.aspx': 'page_white_code.png',
  477. '.bat': 'application_xp_terminal.png',
  478. '.bz2': 'box.png',
  479. '.c': 'page_white_c.png',
  480. '.cab': 'box.png',
  481. '.cfm': 'page_white_coldfusion.png',
  482. '.clj': 'page_white_code.png',
  483. '.cc': 'page_white_cplusplus.png',
  484. '.cgi': 'application_xp_terminal.png',
  485. '.cpp': 'page_white_cplusplus.png',
  486. '.cs': 'page_white_csharp.png',
  487. '.db': 'page_white_database.png',
  488. '.dbf': 'page_white_database.png',
  489. '.deb': 'box.png',
  490. '.dll': 'page_white_gear.png',
  491. '.dmg': 'drive.png',
  492. '.docx': 'page_white_word.png',
  493. '.erb': 'page_white_ruby.png',
  494. '.exe': 'application_xp.png',
  495. '.fnt': 'font.png',
  496. '.gam': 'controller.png',
  497. '.gz': 'box.png',
  498. '.h': 'page_white_h.png',
  499. '.ini': 'page_white_gear.png',
  500. '.iso': 'cd.png',
  501. '.jar': 'box.png',
  502. '.java': 'page_white_cup.png',
  503. '.jsp': 'page_white_cup.png',
  504. '.lua': 'page_white_code.png',
  505. '.lz': 'box.png',
  506. '.lzma': 'box.png',
  507. '.m': 'page_white_code.png',
  508. '.map': 'map.png',
  509. '.msi': 'box.png',
  510. '.mv4': 'film.png',
  511. '.otf': 'font.png',
  512. '.pdb': 'page_white_database.png',
  513. '.php': 'page_white_php.png',
  514. '.pl': 'page_white_code.png',
  515. '.pkg': 'box.png',
  516. '.pptx': 'page_white_powerpoint.png',
  517. '.psd': 'page_white_picture.png',
  518. '.py': 'page_white_code.png',
  519. '.rar': 'box.png',
  520. '.rb': 'page_white_ruby.png',
  521. '.rm': 'film.png',
  522. '.rom': 'controller.png',
  523. '.rpm': 'box.png',
  524. '.sass': 'page_white_code.png',
  525. '.sav': 'controller.png',
  526. '.scss': 'page_white_code.png',
  527. '.srt': 'page_white_text.png',
  528. '.tbz2': 'box.png',
  529. '.tgz': 'box.png',
  530. '.tlz': 'box.png',
  531. '.vb': 'page_white_code.png',
  532. '.vbs': 'page_white_code.png',
  533. '.xcf': 'page_white_picture.png',
  534. '.xlsx': 'page_white_excel.png',
  535. '.yaws': 'page_white_code.png'
  536. };