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.

501 lines
14KB

  1. #! /usr/bin/env node
  2. 'use strict';
  3. const path = require('path');
  4. const fs = require('fs');
  5. const url = require('url');
  6. const mime = require('mime');
  7. const urlJoin = require('url-join');
  8. const showDir = require('./ecstatic/show-dir');
  9. const version = require('../package.json').version;
  10. const status = require('./ecstatic/status-handlers');
  11. const generateEtag = require('./ecstatic/etag');
  12. const optsParser = require('./ecstatic/opts');
  13. let ecstatic = null;
  14. // See: https://github.com/jesusabdullah/node-ecstatic/issues/109
  15. function decodePathname(pathname) {
  16. const pieces = pathname.replace(/\\/g, '/').split('/');
  17. return path.normalize(pieces.map((rawPiece) => {
  18. const piece = decodeURIComponent(rawPiece);
  19. if (process.platform === 'win32' && /\\/.test(piece)) {
  20. throw new Error('Invalid forward slash character');
  21. }
  22. return piece;
  23. }).join('/'));
  24. }
  25. // Check to see if we should try to compress a file with gzip.
  26. function shouldCompressGzip(req) {
  27. const headers = req.headers;
  28. return headers && headers['accept-encoding'] &&
  29. headers['accept-encoding']
  30. .split(',')
  31. .some(el => ['*', 'compress', 'gzip', 'deflate'].indexOf(el.trim()) !== -1)
  32. ;
  33. }
  34. function shouldCompressBrotli(req) {
  35. const headers = req.headers;
  36. return headers && headers['accept-encoding'] &&
  37. headers['accept-encoding']
  38. .split(',')
  39. .some(el => ['*', 'br'].indexOf(el.trim()) !== -1)
  40. ;
  41. }
  42. function hasGzipId12(gzipped, cb) {
  43. const stream = fs.createReadStream(gzipped, { start: 0, end: 1 });
  44. let buffer = Buffer('');
  45. let hasBeenCalled = false;
  46. stream.on('data', (chunk) => {
  47. buffer = Buffer.concat([buffer, chunk], 2);
  48. });
  49. stream.on('error', (err) => {
  50. if (hasBeenCalled) {
  51. throw err;
  52. }
  53. hasBeenCalled = true;
  54. cb(err);
  55. });
  56. stream.on('close', () => {
  57. if (hasBeenCalled) {
  58. return;
  59. }
  60. hasBeenCalled = true;
  61. cb(null, buffer[0] === 31 && buffer[1] === 139);
  62. });
  63. }
  64. module.exports = function createMiddleware(_dir, _options) {
  65. let dir;
  66. let options;
  67. if (typeof _dir === 'string') {
  68. dir = _dir;
  69. options = _options;
  70. } else {
  71. options = _dir;
  72. dir = options.root;
  73. }
  74. const root = path.join(path.resolve(dir), '/');
  75. const opts = optsParser(options);
  76. const cache = opts.cache;
  77. const autoIndex = opts.autoIndex;
  78. const baseDir = opts.baseDir;
  79. let defaultExt = opts.defaultExt;
  80. const handleError = opts.handleError;
  81. const headers = opts.headers;
  82. const serverHeader = opts.serverHeader;
  83. const weakEtags = opts.weakEtags;
  84. const handleOptionsMethod = opts.handleOptionsMethod;
  85. opts.root = dir;
  86. if (defaultExt && /^\./.test(defaultExt)) {
  87. defaultExt = defaultExt.replace(/^\./, '');
  88. }
  89. // Support hashes and .types files in mimeTypes @since 0.8
  90. if (opts.mimeTypes) {
  91. try {
  92. // You can pass a JSON blob here---useful for CLI use
  93. opts.mimeTypes = JSON.parse(opts.mimeTypes);
  94. } catch (e) {
  95. // swallow parse errors, treat this as a string mimetype input
  96. }
  97. if (typeof opts.mimeTypes === 'string') {
  98. mime.load(opts.mimeTypes);
  99. } else if (typeof opts.mimeTypes === 'object') {
  100. mime.define(opts.mimeTypes);
  101. }
  102. }
  103. function shouldReturn304(req, serverLastModified, serverEtag) {
  104. if (!req || !req.headers) {
  105. return false;
  106. }
  107. const clientModifiedSince = req.headers['if-modified-since'];
  108. const clientEtag = req.headers['if-none-match'];
  109. let clientModifiedDate;
  110. if (!clientModifiedSince && !clientEtag) {
  111. // Client did not provide any conditional caching headers
  112. return false;
  113. }
  114. if (clientModifiedSince) {
  115. // Catch "illegal access" dates that will crash v8
  116. // https://github.com/jfhbrook/node-ecstatic/pull/179
  117. try {
  118. clientModifiedDate = new Date(Date.parse(clientModifiedSince));
  119. } catch (err) {
  120. return false;
  121. }
  122. if (clientModifiedDate.toString() === 'Invalid Date') {
  123. return false;
  124. }
  125. // If the client's copy is older than the server's, don't return 304
  126. if (clientModifiedDate < new Date(serverLastModified)) {
  127. return false;
  128. }
  129. }
  130. if (clientEtag) {
  131. // Do a strong or weak etag comparison based on setting
  132. // https://www.ietf.org/rfc/rfc2616.txt Section 13.3.3
  133. if (opts.weakCompare && clientEtag !== serverEtag
  134. && clientEtag !== `W/${serverEtag}` && `W/${clientEtag}` !== serverEtag) {
  135. return false;
  136. } else if (!opts.weakCompare && (clientEtag !== serverEtag || clientEtag.indexOf('W/') === 0)) {
  137. return false;
  138. }
  139. }
  140. return true;
  141. }
  142. return function middleware(req, res, next) {
  143. // Figure out the path for the file from the given url
  144. const parsed = url.parse(req.url);
  145. let pathname = null;
  146. let file = null;
  147. let gzippedFile = null;
  148. let brotliFile = null;
  149. // Strip any null bytes from the url
  150. // This was at one point necessary because of an old bug in url.parse
  151. //
  152. // See: https://github.com/jfhbrook/node-ecstatic/issues/16#issuecomment-3039914
  153. // See: https://github.com/jfhbrook/node-ecstatic/commit/43f7e72a31524f88f47e367c3cc3af710e67c9f4
  154. //
  155. // But this opens up a regex dos attack vector! D:
  156. //
  157. // Based on some research (ie asking #node-dev if this is still an issue),
  158. // it's *probably* not an issue. :)
  159. /*
  160. while (req.url.indexOf('%00') !== -1) {
  161. req.url = req.url.replace(/\%00/g, '');
  162. }
  163. */
  164. try {
  165. decodeURIComponent(req.url); // check validity of url
  166. pathname = decodePathname(parsed.pathname);
  167. } catch (err) {
  168. status[400](res, next, { error: err });
  169. return;
  170. }
  171. file = path.normalize(
  172. path.join(
  173. root,
  174. path.relative(path.join('/', baseDir), pathname)
  175. )
  176. );
  177. // determine compressed forms if they were to exist
  178. gzippedFile = `${file}.gz`;
  179. brotliFile = `${file}.br`;
  180. if (serverHeader !== false) {
  181. // Set common headers.
  182. res.setHeader('server', `ecstatic-${version}`);
  183. }
  184. Object.keys(headers).forEach((key) => {
  185. res.setHeader(key, headers[key]);
  186. });
  187. if (req.method === 'OPTIONS' && handleOptionsMethod) {
  188. res.end();
  189. return;
  190. }
  191. // TODO: This check is broken, which causes the 403 on the
  192. // expected 404.
  193. if (file.slice(0, root.length) !== root) {
  194. status[403](res, next);
  195. return;
  196. }
  197. if (req.method && (req.method !== 'GET' && req.method !== 'HEAD')) {
  198. status[405](res, next);
  199. return;
  200. }
  201. function serve(stat) {
  202. // Do a MIME lookup, fall back to octet-stream and handle gzip
  203. // and brotli special case.
  204. const defaultType = opts.contentType || 'application/octet-stream';
  205. let contentType = mime.lookup(file, defaultType);
  206. let charSet;
  207. const range = (req.headers && req.headers.range);
  208. const lastModified = (new Date(stat.mtime)).toUTCString();
  209. const etag = generateEtag(stat, weakEtags);
  210. let cacheControl = cache;
  211. let stream = null;
  212. if (contentType) {
  213. charSet = mime.charsets.lookup(contentType, 'utf-8');
  214. if (charSet) {
  215. contentType += `; charset=${charSet}`;
  216. }
  217. }
  218. if (file === gzippedFile) { // is .gz picked up
  219. res.setHeader('Content-Encoding', 'gzip');
  220. // strip gz ending and lookup mime type
  221. contentType = mime.lookup(path.basename(file, '.gz'), defaultType);
  222. } else if (file === brotliFile) { // is .br picked up
  223. res.setHeader('Content-Encoding', 'br');
  224. // strip br ending and lookup mime type
  225. contentType = mime.lookup(path.basename(file, '.br'), defaultType);
  226. }
  227. if (typeof cacheControl === 'function') {
  228. cacheControl = cache(pathname);
  229. }
  230. if (typeof cacheControl === 'number') {
  231. cacheControl = `max-age=${cacheControl}`;
  232. }
  233. if (range) {
  234. const total = stat.size;
  235. const parts = range.trim().replace(/bytes=/, '').split('-');
  236. const partialstart = parts[0];
  237. const partialend = parts[1];
  238. const start = parseInt(partialstart, 10);
  239. const end = Math.min(
  240. total - 1,
  241. partialend ? parseInt(partialend, 10) : total - 1
  242. );
  243. const chunksize = (end - start) + 1;
  244. let fstream = null;
  245. if (start > end || isNaN(start) || isNaN(end)) {
  246. status['416'](res, next);
  247. return;
  248. }
  249. fstream = fs.createReadStream(file, { start, end });
  250. fstream.on('error', (err) => {
  251. status['500'](res, next, { error: err });
  252. });
  253. res.on('close', () => {
  254. fstream.destroy();
  255. });
  256. res.writeHead(206, {
  257. 'Content-Range': `bytes ${start}-${end}/${total}`,
  258. 'Accept-Ranges': 'bytes',
  259. 'Content-Length': chunksize,
  260. 'Content-Type': contentType,
  261. 'cache-control': cacheControl,
  262. 'last-modified': lastModified,
  263. etag,
  264. });
  265. fstream.pipe(res);
  266. return;
  267. }
  268. // TODO: Helper for this, with default headers.
  269. res.setHeader('cache-control', cacheControl);
  270. res.setHeader('last-modified', lastModified);
  271. res.setHeader('etag', etag);
  272. // Return a 304 if necessary
  273. if (shouldReturn304(req, lastModified, etag)) {
  274. status[304](res, next);
  275. return;
  276. }
  277. res.setHeader('content-length', stat.size);
  278. res.setHeader('content-type', contentType);
  279. // set the response statusCode if we have a request statusCode.
  280. // This only can happen if we have a 404 with some kind of 404.html
  281. // In all other cases where we have a file we serve the 200
  282. res.statusCode = req.statusCode || 200;
  283. if (req.method === 'HEAD') {
  284. res.end();
  285. return;
  286. }
  287. stream = fs.createReadStream(file);
  288. stream.pipe(res);
  289. stream.on('error', (err) => {
  290. status['500'](res, next, { error: err });
  291. });
  292. }
  293. function statFile() {
  294. fs.stat(file, (err, stat) => {
  295. if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
  296. if (req.statusCode === 404) {
  297. // This means we're already trying ./404.html and can not find it.
  298. // So send plain text response with 404 status code
  299. status[404](res, next);
  300. } else if (!path.extname(parsed.pathname).length && defaultExt) {
  301. // If there is no file extension in the path and we have a default
  302. // extension try filename and default extension combination before rendering 404.html.
  303. middleware({
  304. url: `${parsed.pathname}.${defaultExt}${(parsed.search) ? parsed.search : ''}`,
  305. headers: req.headers,
  306. }, res, next);
  307. } else {
  308. // Try to serve default ./404.html
  309. middleware({
  310. url: (handleError ? `/${path.join(baseDir, `404.${defaultExt}`)}` : req.url),
  311. headers: req.headers,
  312. statusCode: 404,
  313. }, res, next);
  314. }
  315. } else if (err) {
  316. status[500](res, next, { error: err });
  317. } else if (stat.isDirectory()) {
  318. if (!autoIndex && !opts.showDir) {
  319. status[404](res, next);
  320. return;
  321. }
  322. // 302 to / if necessary
  323. if (!pathname.match(/\/$/)) {
  324. res.statusCode = 302;
  325. const q = parsed.query ? `?${parsed.query}` : '';
  326. res.setHeader('location', `${parsed.pathname}/${q}`);
  327. res.end();
  328. return;
  329. }
  330. if (autoIndex) {
  331. middleware({
  332. url: urlJoin(
  333. encodeURIComponent(pathname),
  334. `/index.${defaultExt}`
  335. ),
  336. headers: req.headers,
  337. }, res, (autoIndexError) => {
  338. if (autoIndexError) {
  339. status[500](res, next, { error: autoIndexError });
  340. return;
  341. }
  342. if (opts.showDir) {
  343. showDir(opts, stat)(req, res);
  344. return;
  345. }
  346. status[403](res, next);
  347. });
  348. return;
  349. }
  350. if (opts.showDir) {
  351. showDir(opts, stat)(req, res);
  352. }
  353. } else {
  354. serve(stat);
  355. }
  356. });
  357. }
  358. // serve gzip file if exists and is valid
  359. function tryServeWithGzip() {
  360. fs.stat(gzippedFile, (err, stat) => {
  361. if (!err && stat.isFile()) {
  362. hasGzipId12(gzippedFile, (gzipErr, isGzip) => {
  363. if (!gzipErr && isGzip) {
  364. file = gzippedFile;
  365. serve(stat);
  366. } else {
  367. statFile();
  368. }
  369. });
  370. } else {
  371. statFile();
  372. }
  373. });
  374. }
  375. // serve brotli file if exists, otherwise try gzip
  376. function tryServeWithBrotli(shouldTryGzip) {
  377. fs.stat(brotliFile, (err, stat) => {
  378. if (!err && stat.isFile()) {
  379. file = brotliFile;
  380. serve(stat);
  381. } else if (shouldTryGzip) {
  382. tryServeWithGzip();
  383. } else {
  384. statFile();
  385. }
  386. });
  387. }
  388. const shouldTryBrotli = opts.brotli && shouldCompressBrotli(req);
  389. const shouldTryGzip = opts.gzip && shouldCompressGzip(req);
  390. // always try brotli first, next try gzip, finally serve without compression
  391. if (shouldTryBrotli) {
  392. tryServeWithBrotli(shouldTryGzip);
  393. } else if (shouldTryGzip) {
  394. tryServeWithGzip();
  395. } else {
  396. statFile();
  397. }
  398. };
  399. };
  400. ecstatic = module.exports;
  401. ecstatic.version = version;
  402. ecstatic.showDir = showDir;
  403. if (!module.parent) {
  404. /* eslint-disable global-require */
  405. /* eslint-disable no-console */
  406. const defaults = require('./ecstatic/defaults.json');
  407. const http = require('http');
  408. const minimist = require('minimist');
  409. const aliases = require('./ecstatic/aliases.json');
  410. const opts = minimist(process.argv.slice(2), {
  411. alias: aliases,
  412. default: defaults,
  413. boolean: Object.keys(defaults).filter(
  414. key => typeof defaults[key] === 'boolean'
  415. ),
  416. });
  417. const envPORT = parseInt(process.env.PORT, 10);
  418. const port = envPORT > 1024 && envPORT <= 65536 ? envPORT : opts.port || opts.p || 8000;
  419. const dir = opts.root || opts._[0] || process.cwd();
  420. if (opts.help || opts.h) {
  421. console.error('usage: ecstatic [dir] {options} --port PORT');
  422. console.error('see https://npm.im/ecstatic for more docs');
  423. } else {
  424. http.createServer(ecstatic(dir, opts))
  425. .listen(port, () => {
  426. console.log(`ecstatic serving ${dir} at http://0.0.0.0:${port}`);
  427. })
  428. ;
  429. }
  430. }