1 |
|
2 | const {promisify} = require('util');
|
3 | const path = require('path');
|
4 | const {createHash} = require('crypto');
|
5 | const {realpath, lstat, createReadStream, readdir} = require('fs');
|
6 |
|
7 |
|
8 | const url = require('fast-url-parser');
|
9 | const slasher = require('./glob-slash');
|
10 | const minimatch = require('minimatch');
|
11 | const pathToRegExp = require('path-to-regexp');
|
12 | const mime = require('mime-types');
|
13 | const bytes = require('bytes');
|
14 | const contentDisposition = require('content-disposition');
|
15 | const isPathInside = require('path-is-inside');
|
16 | const parseRange = require('range-parser');
|
17 |
|
18 |
|
19 | const directoryTemplate = require('./directory');
|
20 | const errorTemplate = require('./error');
|
21 |
|
22 | const etags = new Map();
|
23 |
|
24 | const 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 |
|
38 | const 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 |
|
53 |
|
54 |
|
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 |
|
69 | const 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 |
|
91 | const applyRewrites = (requestPath, rewrites = [], repetitive) => {
|
92 |
|
93 | const rewritesCopy = rewrites.slice();
|
94 |
|
95 |
|
96 |
|
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 |
|
109 | rewritesCopy.splice(index, 1);
|
110 |
|
111 |
|
112 | return applyRewrites(slasher(target), rewritesCopy, true);
|
113 | }
|
114 | }
|
115 |
|
116 | return fallback;
|
117 | };
|
118 |
|
119 | const ensureSlashStart = target => (target.startsWith('/') ? target : `/${target}`);
|
120 |
|
121 | const 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 |
|
131 |
|
132 |
|
133 |
|
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 |
|
171 |
|
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 |
|
187 | const 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 |
|
194 | const 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 |
|
202 |
|
203 |
|
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 |
|
219 |
|
220 |
|
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 |
|
256 | const 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 |
|
276 | const 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 |
|
281 | const 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 |
|
309 | const 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 |
|
325 | const 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 |
|
351 |
|
352 |
|
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 |
|
399 | files = files.sort((a, b) => {
|
400 | const aIsDir = a.type === 'directory';
|
401 | const bIsDir = b.type === 'directory';
|
402 |
|
403 |
|
404 | if (aIsDir && !bIsDir) {
|
405 | return -1;
|
406 | }
|
407 |
|
408 | if ((bIsDir && !aIsDir) || (a.base > b.base)) {
|
409 | return 1;
|
410 | }
|
411 |
|
412 |
|
413 | if (a.base < b.base) {
|
414 | return -1;
|
415 | }
|
416 |
|
417 |
|
418 | return 0;
|
419 | }).filter(Boolean);
|
420 |
|
421 |
|
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 |
|
467 | const sendError = async (absolutePath, response, acceptsJSON, current, handlers, config, spec) => {
|
468 | const {err: original, message, code, statusCode} = spec;
|
469 |
|
470 |
|
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 |
|
526 | const 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 |
|
540 | const getHandlers = methods => Object.assign({
|
541 | lstat: promisify(lstat),
|
542 | realpath: promisify(realpath),
|
543 | createReadStream,
|
544 | readdir: promisify(readdir),
|
545 | sendError
|
546 | }, methods);
|
547 |
|
548 | module.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 |
|
573 |
|
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 |
|
597 |
|
598 |
|
599 |
|
600 |
|
601 |
|
602 |
|
603 |
|
604 |
|
605 |
|
606 |
|
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 |
|
677 |
|
678 | stats = null;
|
679 | }
|
680 | }
|
681 |
|
682 | const isSymLink = stats && stats.isSymbolicLink();
|
683 |
|
684 |
|
685 |
|
686 |
|
687 | if (!stats || (!config.symlinks && isSymLink)) {
|
688 |
|
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 |
|
697 |
|
698 |
|
699 | if (isSymLink) {
|
700 | absolutePath = await handlers.realpath(absolutePath);
|
701 | stats = await handlers.lstat(absolutePath);
|
702 | }
|
703 |
|
704 | const streamOpts = {};
|
705 |
|
706 |
|
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 |
|
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 |
|
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 |
|
742 |
|
743 |
|
744 |
|
745 |
|
746 |
|
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 | };
|