ecstatic.js 13 KB

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