index.js 14 KB

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