UNPKG

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