UNPKG

19.5 kBJavaScriptView Raw
1// Native
2const {promisify} = require('util');
3const path = require('path');
4const {createHash} = require('crypto');
5const {realpath, lstat, createReadStream, readdir} = require('fs');
6
7// Packages
8const url = require('fast-url-parser');
9const slasher = require('./glob-slash');
10const minimatch = require('minimatch');
11const pathToRegExp = require('path-to-regexp');
12const mime = require('mime-types');
13const bytes = require('bytes');
14const contentDisposition = require('content-disposition');
15const isPathInside = require('path-is-inside');
16const parseRange = require('range-parser');
17
18// Other
19const directoryTemplate = require('./directory');
20const errorTemplate = require('./error');
21
22const etags = new Map();
23
24const calculateSha = (handlers, absolutePath) =>
25 new Promise((resolve, reject) => {
26 const hash = createHash('sha1');
27 hash.update(path.extname(absolutePath));
28 hash.update('-');
29 const rs = handlers.createReadStream(absolutePath);
30 rs.on('error', reject);
31 rs.on('data', buf => hash.update(buf));
32 rs.on('end', () => {
33 const sha = hash.digest('hex');
34 resolve(sha);
35 });
36 });
37
38const sourceMatches = (source, requestPath, allowSegments) => {
39 const keys = [];
40 const slashed = slasher(source);
41 const resolvedPath = path.posix.resolve(requestPath);
42
43 let results = null;
44
45 if (allowSegments) {
46 const normalized = slashed.replace('*', '(.*)');
47 const expression = pathToRegExp(normalized, keys);
48
49 results = expression.exec(resolvedPath);
50
51 if (!results) {
52 // clear keys so that they are not used
53 // later with empty results. this may
54 // happen if minimatch returns true
55 keys.length = 0;
56 }
57 }
58
59 if (results || minimatch(resolvedPath, slashed)) {
60 return {
61 keys,
62 results
63 };
64 }
65
66 return null;
67};
68
69const toTarget = (source, destination, previousPath) => {
70 const matches = sourceMatches(source, previousPath, true);
71
72 if (!matches) {
73 return null;
74 }
75
76 const {keys, results} = matches;
77
78 const props = {};
79 const {protocol} = url.parse(destination);
80 const normalizedDest = protocol ? destination : slasher(destination);
81 const toPath = pathToRegExp.compile(normalizedDest);
82
83 for (let index = 0; index < keys.length; index++) {
84 const {name} = keys[index];
85 props[name] = results[index + 1];
86 }
87
88 return toPath(props);
89};
90
91const applyRewrites = (requestPath, rewrites = [], repetitive) => {
92 // We need to copy the array, since we're going to modify it.
93 const rewritesCopy = rewrites.slice();
94
95 // If the method was called again, the path was already rewritten
96 // so we need to make sure to return it.
97 const fallback = repetitive ? requestPath : null;
98
99 if (rewritesCopy.length === 0) {
100 return fallback;
101 }
102
103 for (let index = 0; index < rewritesCopy.length; index++) {
104 const {source, destination} = rewrites[index];
105 const target = toTarget(source, destination, requestPath);
106
107 if (target) {
108 // Remove rules that were already applied
109 rewritesCopy.splice(index, 1);
110
111 // Check if there are remaining ones to be applied
112 return applyRewrites(slasher(target), rewritesCopy, true);
113 }
114 }
115
116 return fallback;
117};
118
119const ensureSlashStart = target => (target.startsWith('/') ? target : `/${target}`);
120
121const shouldRedirect = (decodedPath, {redirects = [], trailingSlash}, cleanUrl) => {
122 const slashing = typeof trailingSlash === 'boolean';
123 const defaultType = 301;
124 const matchHTML = /(\.html|\/index)$/g;
125
126 if (redirects.length === 0 && !slashing && !cleanUrl) {
127 return null;
128 }
129
130 // By stripping the HTML parts from the decoded
131 // path *before* handling the trailing slash, we make
132 // sure that only *one* redirect occurs if both
133 // config options are used.
134 if (cleanUrl && matchHTML.test(decodedPath)) {
135 decodedPath = decodedPath.replace(matchHTML, '');
136 if (decodedPath.indexOf('//') > -1) {
137 decodedPath = decodedPath.replace(/\/+/g, '/');
138 }
139 return {
140 target: ensureSlashStart(decodedPath),
141 statusCode: defaultType
142 };
143 }
144
145 if (slashing) {
146 const {ext, name} = path.parse(decodedPath);
147 const isTrailed = decodedPath.endsWith('/');
148 const isDotfile = name.startsWith('.');
149
150 let target = null;
151
152 if (!trailingSlash && isTrailed) {
153 target = decodedPath.slice(0, -1);
154 } else if (trailingSlash && !isTrailed && !ext && !isDotfile) {
155 target = `${decodedPath}/`;
156 }
157
158 if (decodedPath.indexOf('//') > -1) {
159 target = decodedPath.replace(/\/+/g, '/');
160 }
161
162 if (target) {
163 return {
164 target: ensureSlashStart(target),
165 statusCode: defaultType
166 };
167 }
168 }
169
170 // This is currently the fastest way to
171 // iterate over an array
172 for (let index = 0; index < redirects.length; index++) {
173 const {source, destination, type} = redirects[index];
174 const target = toTarget(source, destination, decodedPath);
175
176 if (target) {
177 return {
178 target,
179 statusCode: type || defaultType
180 };
181 }
182 }
183
184 return null;
185};
186
187const appendHeaders = (target, source) => {
188 for (let index = 0; index < source.length; index++) {
189 const {key, value} = source[index];
190 target[key] = value;
191 }
192};
193
194const getHeaders = async (handlers, config, current, absolutePath, stats) => {
195 const {headers: customHeaders = [], etag = false} = config;
196 const related = {};
197 const {base} = path.parse(absolutePath);
198 const relativePath = path.relative(current, absolutePath);
199
200 if (customHeaders.length > 0) {
201 // By iterating over all headers and never stopping, developers
202 // can specify multiple header sources in the config that
203 // might match a single path.
204 for (let index = 0; index < customHeaders.length; index++) {
205 const {source, headers} = customHeaders[index];
206
207 if (sourceMatches(source, slasher(relativePath))) {
208 appendHeaders(related, headers);
209 }
210 }
211 }
212
213 let defaultHeaders = {};
214
215 if (stats) {
216 defaultHeaders = {
217 'Content-Length': stats.size,
218 // Default to "inline", which always tries to render in the browser,
219 // if that's not working, it will save the file. But to be clear: This
220 // only happens if it cannot find a appropiate value.
221 'Content-Disposition': contentDisposition(base, {
222 type: 'inline'
223 }),
224 'Accept-Ranges': 'bytes'
225 };
226
227 if (etag) {
228 let [mtime, sha] = etags.get(absolutePath) || [];
229 if (Number(mtime) !== Number(stats.mtime)) {
230 sha = await calculateSha(handlers, absolutePath);
231 etags.set(absolutePath, [stats.mtime, sha]);
232 }
233 defaultHeaders['ETag'] = `"${sha}"`;
234 } else {
235 defaultHeaders['Last-Modified'] = stats.mtime.toUTCString();
236 }
237
238 const contentType = mime.contentType(base);
239
240 if (contentType) {
241 defaultHeaders['Content-Type'] = contentType;
242 }
243 }
244
245 const headers = Object.assign(defaultHeaders, related);
246
247 for (const key in headers) {
248 if (headers.hasOwnProperty(key) && headers[key] === null) {
249 delete headers[key];
250 }
251 }
252
253 return headers;
254};
255
256const applicable = (decodedPath, configEntry) => {
257 if (typeof configEntry === 'boolean') {
258 return configEntry;
259 }
260
261 if (Array.isArray(configEntry)) {
262 for (let index = 0; index < configEntry.length; index++) {
263 const source = configEntry[index];
264
265 if (sourceMatches(source, decodedPath)) {
266 return true;
267 }
268 }
269
270 return false;
271 }
272
273 return true;
274};
275
276const getPossiblePaths = (relativePath, extension) => [
277 path.join(relativePath, `index${extension}`),
278 relativePath.endsWith('/') ? relativePath.replace(/\/$/g, extension) : (relativePath + extension)
279].filter(item => path.basename(item) !== extension);
280
281const findRelated = async (current, relativePath, rewrittenPath, originalStat) => {
282 const possible = rewrittenPath ? [rewrittenPath] : getPossiblePaths(relativePath, '.html');
283
284 let stats = null;
285
286 for (let index = 0; index < possible.length; index++) {
287 const related = possible[index];
288 const absolutePath = path.join(current, related);
289
290 try {
291 stats = await originalStat(absolutePath);
292 } catch (err) {
293 if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
294 throw err;
295 }
296 }
297
298 if (stats) {
299 return {
300 stats,
301 absolutePath
302 };
303 }
304 }
305
306 return null;
307};
308
309const canBeListed = (excluded, file) => {
310 const slashed = slasher(file);
311 let whether = true;
312
313 for (let mark = 0; mark < excluded.length; mark++) {
314 const source = excluded[mark];
315
316 if (sourceMatches(source, slashed)) {
317 whether = false;
318 break;
319 }
320 }
321
322 return whether;
323};
324
325const renderDirectory = async (current, acceptsJSON, handlers, methods, config, paths) => {
326 const {directoryListing, trailingSlash, unlisted = [], renderSingle} = config;
327 const slashSuffix = typeof trailingSlash === 'boolean' ? (trailingSlash ? '/' : '') : '/';
328 const {relativePath, absolutePath} = paths;
329
330 const excluded = [
331 '.DS_Store',
332 '.git',
333 ...unlisted
334 ];
335
336 if (!applicable(relativePath, directoryListing) && !renderSingle) {
337 return {};
338 }
339
340 let files = await handlers.readdir(absolutePath);
341
342 const canRenderSingle = renderSingle && (files.length === 1);
343
344 for (let index = 0; index < files.length; index++) {
345 const file = files[index];
346
347 const filePath = path.resolve(absolutePath, file);
348 const details = path.parse(filePath);
349
350 // It's important to indicate that the `stat` call was
351 // spawned by the directory listing, as Now is
352 // simulating those calls and needs to special-case this.
353 let stats = null;
354
355 if (methods.lstat) {
356 stats = await handlers.lstat(filePath, true);
357 } else {
358 stats = await handlers.lstat(filePath);
359 }
360
361 details.relative = path.join(relativePath, details.base);
362
363 if (stats.isDirectory()) {
364 details.base += slashSuffix;
365 details.relative += slashSuffix;
366 details.type = 'folder';
367 } else {
368 if (canRenderSingle) {
369 return {
370 singleFile: true,
371 absolutePath: filePath,
372 stats
373 };
374 }
375
376 details.ext = details.ext.split('.')[1] || 'txt';
377 details.type = 'file';
378
379 details.size = bytes(stats.size, {
380 unitSeparator: ' ',
381 decimalPlaces: 0
382 });
383 }
384
385 details.title = details.base;
386
387 if (canBeListed(excluded, file)) {
388 files[index] = details;
389 } else {
390 delete files[index];
391 }
392 }
393
394 const toRoot = path.relative(current, absolutePath);
395 const directory = path.join(path.basename(current), toRoot, slashSuffix);
396 const pathParts = directory.split(path.sep).filter(Boolean);
397
398 // Sort to list directories first, then sort alphabetically
399 files = files.sort((a, b) => {
400 const aIsDir = a.type === 'directory';
401 const bIsDir = b.type === 'directory';
402
403 /* istanbul ignore next */
404 if (aIsDir && !bIsDir) {
405 return -1;
406 }
407
408 if ((bIsDir && !aIsDir) || (a.base > b.base)) {
409 return 1;
410 }
411
412 /* istanbul ignore next */
413 if (a.base < b.base) {
414 return -1;
415 }
416
417 /* istanbul ignore next */
418 return 0;
419 }).filter(Boolean);
420
421 // Add parent directory to the head of the sorted files array
422 if (toRoot.length > 0) {
423 const directoryPath = [...pathParts].slice(1);
424 const relative = path.join('/', ...directoryPath, '..', slashSuffix);
425
426 files.unshift({
427 type: 'directory',
428 base: '..',
429 relative,
430 title: relative,
431 ext: ''
432 });
433 }
434
435 const subPaths = [];
436
437 for (let index = 0; index < pathParts.length; index++) {
438 const parents = [];
439 const isLast = index === (pathParts.length - 1);
440
441 let before = 0;
442
443 while (before <= index) {
444 parents.push(pathParts[before]);
445 before++;
446 }
447
448 parents.shift();
449
450 subPaths.push({
451 name: pathParts[index] + (isLast ? slashSuffix : '/'),
452 url: index === 0 ? '' : parents.join('/') + slashSuffix
453 });
454 }
455
456 const spec = {
457 files,
458 directory,
459 paths: subPaths
460 };
461
462 const output = acceptsJSON ? JSON.stringify(spec) : directoryTemplate(spec);
463
464 return {directory: output};
465};
466
467const sendError = async (absolutePath, response, acceptsJSON, current, handlers, config, spec) => {
468 const {err: original, message, code, statusCode} = spec;
469
470 /* istanbul ignore next */
471 if (original && process.env.NODE_ENV !== 'test') {
472 console.error(original);
473 }
474
475 response.statusCode = statusCode;
476
477 if (acceptsJSON) {
478 response.setHeader('Content-Type', 'application/json; charset=utf-8');
479
480 response.end(JSON.stringify({
481 error: {
482 code,
483 message
484 }
485 }));
486
487 return;
488 }
489
490 let stats = null;
491
492 const errorPage = path.join(current, `${statusCode}.html`);
493
494 try {
495 stats = await handlers.lstat(errorPage);
496 } catch (err) {
497 if (err.code !== 'ENOENT') {
498 console.error(err);
499 }
500 }
501
502 if (stats) {
503 let stream = null;
504
505 try {
506 stream = await handlers.createReadStream(errorPage);
507
508 const headers = await getHeaders(handlers, config, current, errorPage, stats);
509
510 response.writeHead(statusCode, headers);
511 stream.pipe(response);
512
513 return;
514 } catch (err) {
515 console.error(err);
516 }
517 }
518
519 const headers = await getHeaders(handlers, config, current, absolutePath, null);
520 headers['Content-Type'] = 'text/html; charset=utf-8';
521
522 response.writeHead(statusCode, headers);
523 response.end(errorTemplate({statusCode, message}));
524};
525
526const internalError = async (...args) => {
527 const lastIndex = args.length - 1;
528 const err = args[lastIndex];
529
530 args[lastIndex] = {
531 statusCode: 500,
532 code: 'internal_server_error',
533 message: 'A server error has occurred',
534 err
535 };
536
537 return sendError(...args);
538};
539
540const getHandlers = methods => Object.assign({
541 lstat: promisify(lstat),
542 realpath: promisify(realpath),
543 createReadStream,
544 readdir: promisify(readdir),
545 sendError
546}, methods);
547
548module.exports = async (request, response, config = {}, methods = {}) => {
549 const cwd = process.cwd();
550 const current = config.public ? path.resolve(cwd, config.public) : cwd;
551 const handlers = getHandlers(methods);
552
553 let relativePath = null;
554 let acceptsJSON = null;
555
556 if (request.headers.accept) {
557 acceptsJSON = request.headers.accept.includes('application/json');
558 }
559
560 try {
561 relativePath = decodeURIComponent(url.parse(request.url).pathname);
562 } catch (err) {
563 return sendError('/', response, acceptsJSON, current, handlers, config, {
564 statusCode: 400,
565 code: 'bad_request',
566 message: 'Bad Request'
567 });
568 }
569
570 let absolutePath = path.join(current, relativePath);
571
572 // Prevent path traversal vulnerabilities. We could do this
573 // by ourselves, but using the package covers all the edge cases.
574 if (!isPathInside(absolutePath, current)) {
575 return sendError(absolutePath, response, acceptsJSON, current, handlers, config, {
576 statusCode: 400,
577 code: 'bad_request',
578 message: 'Bad Request'
579 });
580 }
581
582 const cleanUrl = applicable(relativePath, config.cleanUrls);
583 const redirect = shouldRedirect(relativePath, config, cleanUrl);
584
585 if (redirect) {
586 response.writeHead(redirect.statusCode, {
587 Location: encodeURI(redirect.target)
588 });
589
590 response.end();
591 return;
592 }
593
594 let stats = null;
595
596 // It's extremely important that we're doing multiple stat calls. This one
597 // right here could technically be removed, but then the program
598 // would be slower. Because for directories, we always want to see if a related file
599 // exists and then (after that), fetch the directory itself if no
600 // related file was found. However (for files, of which most have extensions), we should
601 // always stat right away.
602 //
603 // When simulating a file system without directory indexes, calculating whether a
604 // directory exists requires loading all the file paths and then checking if
605 // one of them includes the path of the directory. As that's a very
606 // performance-expensive thing to do, we need to ensure it's not happening if not really necessary.
607
608 if (path.extname(relativePath) !== '') {
609 try {
610 stats = await handlers.lstat(absolutePath);
611 } catch (err) {
612 if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
613 return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
614 }
615 }
616 }
617
618 const rewrittenPath = applyRewrites(relativePath, config.rewrites);
619
620 if (!stats && (cleanUrl || rewrittenPath)) {
621 try {
622 const related = await findRelated(current, relativePath, rewrittenPath, handlers.lstat);
623
624 if (related) {
625 ({stats, absolutePath} = related);
626 }
627 } catch (err) {
628 if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
629 return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
630 }
631 }
632 }
633
634 if (!stats) {
635 try {
636 stats = await handlers.lstat(absolutePath);
637 } catch (err) {
638 if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
639 return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
640 }
641 }
642 }
643
644 if (stats && stats.isDirectory()) {
645 let directory = null;
646 let singleFile = null;
647
648 try {
649 const related = await renderDirectory(current, acceptsJSON, handlers, methods, config, {
650 relativePath,
651 absolutePath
652 });
653
654 if (related.singleFile) {
655 ({stats, absolutePath, singleFile} = related);
656 } else {
657 ({directory} = related);
658 }
659 } catch (err) {
660 if (err.code !== 'ENOENT') {
661 return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
662 }
663 }
664
665 if (directory) {
666 const contentType = acceptsJSON ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8';
667
668 response.statusCode = 200;
669 response.setHeader('Content-Type', contentType);
670 response.end(directory);
671
672 return;
673 }
674
675 if (!singleFile) {
676 // The directory listing is disabled, so we want to
677 // render a 404 error.
678 stats = null;
679 }
680 }
681
682 const isSymLink = stats && stats.isSymbolicLink();
683
684 // There are two scenarios in which we want to reply with
685 // a 404 error: Either the path does not exist, or it is a
686 // symlink while the `symlinks` option is disabled (which it is by default).
687 if (!stats || (!config.symlinks && isSymLink)) {
688 // allow for custom 404 handling
689 return handlers.sendError(absolutePath, response, acceptsJSON, current, handlers, config, {
690 statusCode: 404,
691 code: 'not_found',
692 message: 'The requested path could not be found'
693 });
694 }
695
696 // If we figured out that the target is a symlink, we need to
697 // resolve the symlink and run a new `stat` call just for the
698 // target of that symlink.
699 if (isSymLink) {
700 absolutePath = await handlers.realpath(absolutePath);
701 stats = await handlers.lstat(absolutePath);
702 }
703
704 const streamOpts = {};
705
706 // TODO ? if-range
707 if (request.headers.range && stats.size) {
708 const range = parseRange(stats.size, request.headers.range);
709
710 if (typeof range === 'object' && range.type === 'bytes') {
711 const {start, end} = range[0];
712
713 streamOpts.start = start;
714 streamOpts.end = end;
715
716 response.statusCode = 206;
717 } else {
718 response.statusCode = 416;
719 response.setHeader('Content-Range', `bytes */${stats.size}`);
720 }
721 }
722
723 // TODO ? multiple ranges
724
725 let stream = null;
726
727 try {
728 stream = await handlers.createReadStream(absolutePath, streamOpts);
729 } catch (err) {
730 return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
731 }
732
733 const headers = await getHeaders(handlers, config, current, absolutePath, stats);
734
735 // eslint-disable-next-line no-undefined
736 if (streamOpts.start !== undefined && streamOpts.end !== undefined) {
737 headers['Content-Range'] = `bytes ${streamOpts.start}-${streamOpts.end}/${stats.size}`;
738 headers['Content-Length'] = streamOpts.end - streamOpts.start + 1;
739 }
740
741 // We need to check for `headers.ETag` being truthy first, otherwise it will
742 // match `undefined` being equal to `undefined`, which is true.
743 //
744 // Checking for `undefined` and `null` is also important, because `Range` can be `0`.
745 //
746 // eslint-disable-next-line no-eq-null
747 if (request.headers.range == null && headers.ETag && headers.ETag === request.headers['if-none-match']) {
748 response.statusCode = 304;
749 response.end();
750
751 return;
752 }
753
754 response.writeHead(response.statusCode || 200, headers);
755 stream.pipe(response);
756};