123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- #! /usr/bin/env node
- 'use strict';
- const path = require('path');
- const fs = require('fs');
- const url = require('url');
- const { Readable } = require('stream');
- const buffer = require('buffer');
- const mime = require('mime');
- const urlJoin = require('url-join');
- const showDir = require('./show-dir');
- const version = require('../../package.json').version;
- const status = require('./status-handlers');
- const generateEtag = require('./etag');
- const optsParser = require('./opts');
- const htmlEncodingSniffer = require('html-encoding-sniffer');
- let httpServerCore = null;
- function decodePathname(pathname) {
- const pieces = pathname.replace(/\\/g, '/').split('/');
- const normalized = path.normalize(pieces.map((rawPiece) => {
- const piece = decodeURIComponent(rawPiece);
- if (process.platform === 'win32' && /\\/.test(piece)) {
- throw new Error('Invalid forward slash character');
- }
- return piece;
- }).join('/'));
- return process.platform === 'win32'
- ? normalized.replace(/\\/g, '/') : normalized;
- }
- const nonUrlSafeCharsRgx = /[\x00-\x1F\x20\x7F-\uFFFF]+/g;
- function ensureUriEncoded(text) {
- return text
- return String(text).replace(nonUrlSafeCharsRgx, encodeURIComponent);
- }
- // Check to see if we should try to compress a file with gzip.
- function shouldCompressGzip(req) {
- const headers = req.headers;
- return headers && headers['accept-encoding'] &&
- headers['accept-encoding']
- .split(',')
- .some(el => ['*', 'compress', 'gzip', 'deflate'].indexOf(el.trim()) !== -1)
- ;
- }
- function shouldCompressBrotli(req) {
- const headers = req.headers;
- return headers && headers['accept-encoding'] &&
- headers['accept-encoding']
- .split(',')
- .some(el => ['*', 'br'].indexOf(el.trim()) !== -1)
- ;
- }
- function hasGzipId12(gzipped, cb) {
- const stream = fs.createReadStream(gzipped, { start: 0, end: 1 });
- let buffer = Buffer.from('');
- let hasBeenCalled = false;
- stream.on('data', (chunk) => {
- buffer = Buffer.concat([buffer, chunk], 2);
- });
- stream.on('error', (err) => {
- if (hasBeenCalled) {
- throw err;
- }
- hasBeenCalled = true;
- cb(err);
- });
- stream.on('close', () => {
- if (hasBeenCalled) {
- return;
- }
- hasBeenCalled = true;
- cb(null, buffer[0] === 31 && buffer[1] === 139);
- });
- }
- module.exports = function createMiddleware(_dir, _options) {
- let dir;
- let options;
- if (typeof _dir === 'string') {
- dir = _dir;
- options = _options;
- } else {
- options = _dir;
- dir = options.root;
- }
- const root = path.join(path.resolve(dir), '/');
- const opts = optsParser(options);
- const cache = opts.cache;
- const autoIndex = opts.autoIndex;
- const baseDir = opts.baseDir;
- let defaultExt = opts.defaultExt;
- const handleError = opts.handleError;
- const headers = opts.headers;
- const weakEtags = opts.weakEtags;
- const handleOptionsMethod = opts.handleOptionsMethod;
- opts.root = dir;
- if (defaultExt && /^\./.test(defaultExt)) {
- defaultExt = defaultExt.replace(/^\./, '');
- }
- // Support hashes and .types files in mimeTypes @since 0.8
- if (opts.mimeTypes) {
- try {
- // You can pass a JSON blob here---useful for CLI use
- opts.mimeTypes = JSON.parse(opts.mimeTypes);
- } catch (e) {
- // swallow parse errors, treat this as a string mimetype input
- }
- if (typeof opts.mimeTypes === 'string') {
- mime.load(opts.mimeTypes);
- } else if (typeof opts.mimeTypes === 'object') {
- mime.define(opts.mimeTypes);
- }
- }
- function shouldReturn304(req, serverLastModified, serverEtag) {
- if (!req || !req.headers) {
- return false;
- }
- const clientModifiedSince = req.headers['if-modified-since'];
- const clientEtag = req.headers['if-none-match'];
- let clientModifiedDate;
- if (!clientModifiedSince && !clientEtag) {
- // Client did not provide any conditional caching headers
- return false;
- }
- if (clientModifiedSince) {
- // Catch "illegal access" dates that will crash v8
- try {
- clientModifiedDate = new Date(Date.parse(clientModifiedSince));
- } catch (err) {
- return false;
- }
- if (clientModifiedDate.toString() === 'Invalid Date') {
- return false;
- }
- // If the client's copy is older than the server's, don't return 304
- if (clientModifiedDate < new Date(serverLastModified)) {
- return false;
- }
- }
- if (clientEtag) {
- // Do a strong or weak etag comparison based on setting
- // https://www.ietf.org/rfc/rfc2616.txt Section 13.3.3
- if (opts.weakCompare && clientEtag !== serverEtag
- && clientEtag !== `W/${serverEtag}` && `W/${clientEtag}` !== serverEtag) {
- return false;
- }
- if (!opts.weakCompare && (clientEtag !== serverEtag || clientEtag.indexOf('W/') === 0)) {
- return false;
- }
- }
- return true;
- }
- return function middleware(req, res, next) {
- // Figure out the path for the file from the given url
- const parsed = url.parse(req.url);
- let pathname = null;
- let file = null;
- let gzippedFile = null;
- let brotliFile = null;
- try {
- decodeURIComponent(req.url); // check validity of url
- pathname = decodePathname(parsed.pathname);
- } catch (err) {
- status[400](res, next, { error: err });
- return;
- }
- file = path.normalize(
- path.join(
- root,
- path.relative(path.join('/', baseDir), pathname)
- )
- );
- // determine compressed forms if they were to exist
- gzippedFile = `${file}.gz`;
- brotliFile = `${file}.br`;
- Object.keys(headers).forEach((key) => {
- res.setHeader(key, headers[key]);
- });
- if (req.method === 'OPTIONS' && handleOptionsMethod) {
- res.end();
- return;
- }
- // TODO: This check is broken, which causes the 403 on the
- // expected 404.
- if (file.slice(0, root.length) !== root) {
- status[403](res, next);
- return;
- }
- if (req.method && (req.method !== 'GET' && req.method !== 'HEAD')) {
- status[405](res, next);
- return;
- }
- function serve(stat) {
- // Do a MIME lookup, fall back to octet-stream and handle gzip
- // and brotli special case.
- const defaultType = opts.contentType || 'application/octet-stream';
- let contentType = mime.lookup(file, defaultType);
- const range = (req.headers && req.headers.range);
- const lastModified = (new Date(stat.mtime)).toUTCString();
- const etag = generateEtag(stat, weakEtags);
- let cacheControl = cache;
- let stream = null;
- if (contentType && isTextFile(contentType)) {
- if (stat.size < buffer.constants.MAX_LENGTH) {
- const bytes = fs.readFileSync(file);
- const sniffedEncoding = htmlEncodingSniffer(bytes, {
- defaultEncoding: 'UTF-8'
- });
- contentType += `; charset=${sniffedEncoding}`;
- stream = Readable.from(bytes)
- } else {
- // Assume text types are utf8
- contentType += '; charset=UTF-8';
- }
- }
- if (file === gzippedFile) { // is .gz picked up
- res.setHeader('Content-Encoding', 'gzip');
- // strip gz ending and lookup mime type
- contentType = mime.lookup(path.basename(file, '.gz'), defaultType);
- } else if (file === brotliFile) { // is .br picked up
- res.setHeader('Content-Encoding', 'br');
- // strip br ending and lookup mime type
- contentType = mime.lookup(path.basename(file, '.br'), defaultType);
- }
- if (typeof cacheControl === 'function') {
- cacheControl = cache(pathname);
- }
- if (typeof cacheControl === 'number') {
- cacheControl = `max-age=${cacheControl}`;
- }
- if (range) {
- const total = stat.size;
- const parts = range.trim().replace(/bytes=/, '').split('-');
- const partialstart = parts[0];
- const partialend = parts[1];
- const start = parseInt(partialstart, 10);
- const end = Math.min(
- total - 1,
- partialend ? parseInt(partialend, 10) : total - 1
- );
- const chunksize = (end - start) + 1;
- let fstream = null;
- if (start > end || isNaN(start) || isNaN(end)) {
- status['416'](res, next);
- return;
- }
- fstream = fs.createReadStream(file, { start, end });
- fstream.on('error', (err) => {
- status['500'](res, next, { error: err });
- });
- res.on('close', () => {
- fstream.destroy();
- });
- res.writeHead(206, {
- 'Content-Range': `bytes ${start}-${end}/${total}`,
- 'Accept-Ranges': 'bytes',
- 'Content-Length': chunksize,
- 'Content-Type': contentType,
- 'cache-control': cacheControl,
- 'last-modified': lastModified,
- etag,
- });
- fstream.pipe(res);
- return;
- }
- // TODO: Helper for this, with default headers.
- res.setHeader('cache-control', cacheControl);
- res.setHeader('last-modified', lastModified);
- res.setHeader('etag', etag);
- // Return a 304 if necessary
- if (shouldReturn304(req, lastModified, etag)) {
- status[304](res, next);
- return;
- }
- res.setHeader('content-length', stat.size);
- res.setHeader('content-type', contentType);
- // set the response statusCode if we have a request statusCode.
- // This only can happen if we have a 404 with some kind of 404.html
- // In all other cases where we have a file we serve the 200
- res.statusCode = req.statusCode || 200;
- if (req.method === 'HEAD') {
- res.end();
- return;
- }
- // stream may already have been assigned during encoding sniffing.
- if (stream === null) {
- stream = fs.createReadStream(file);
- }
- stream.pipe(res);
- stream.on('error', (err) => {
- status['500'](res, next, { error: err });
- });
- stream.on('close', () => {
- stream.destroy();
- })
- }
- function statFile() {
- try {
- fs.stat(file, (err, stat) => {
- if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
- if (req.statusCode === 404) {
- // This means we're already trying ./404.html and can not find it.
- // So send plain text response with 404 status code
- status[404](res, next);
- } else if (!path.extname(parsed.pathname).length && defaultExt) {
- // If there is no file extension in the path and we have a default
- // extension try filename and default extension combination before rendering 404.html.
- middleware({
- url: `${parsed.pathname}.${defaultExt}${(parsed.search) ? parsed.search : ''}`,
- headers: req.headers,
- }, res, next);
- } else {
- // Try to serve default ./404.html
- const rawUrl = (handleError ? `/${path.join(baseDir, `404.${defaultExt}`)}` : req.url);
- const encodedUrl = ensureUriEncoded(rawUrl);
- middleware({
- url: encodedUrl,
- headers: req.headers,
- statusCode: 404,
- }, res, next);
- }
- } else if (err) {
- status[500](res, next, { error: err });
- } else if (stat.isDirectory()) {
- if (!autoIndex && !opts.showDir) {
- status[404](res, next);
- return;
- }
- // 302 to / if necessary
- if (!pathname.match(/\/$/)) {
- res.statusCode = 302;
- const q = parsed.query ? `?${parsed.query}` : '';
- res.setHeader(
- 'location',
- ensureUriEncoded(`${parsed.pathname}/${q}`)
- );
- res.end();
- return;
- }
- if (autoIndex) {
- middleware({
- url: urlJoin(
- encodeURIComponent(pathname),
- `/index.${defaultExt}`
- ),
- headers: req.headers,
- }, res, (autoIndexError) => {
- if (autoIndexError) {
- status[500](res, next, { error: autoIndexError });
- return;
- }
- if (opts.showDir) {
- showDir(opts, stat)(req, res);
- return;
- }
- status[403](res, next);
- });
- return;
- }
- if (opts.showDir) {
- showDir(opts, stat)(req, res);
- }
- } else {
- serve(stat);
- }
- });
- } catch (err) {
- status[500](res, next, { error: err.message });
- }
- }
- function isTextFile(mimeType) {
- return (/^text\/|^application\/(javascript|json)/).test(mimeType);
- }
- // serve gzip file if exists and is valid
- function tryServeWithGzip() {
- try {
- fs.stat(gzippedFile, (err, stat) => {
- if (!err && stat.isFile()) {
- hasGzipId12(gzippedFile, (gzipErr, isGzip) => {
- if (!gzipErr && isGzip) {
- file = gzippedFile;
- serve(stat);
- } else {
- statFile();
- }
- });
- } else {
- statFile();
- }
- });
- } catch (err) {
- status[500](res, next, { error: err.message });
- }
- }
- // serve brotli file if exists, otherwise try gzip
- function tryServeWithBrotli(shouldTryGzip) {
- try {
- fs.stat(brotliFile, (err, stat) => {
- if (!err && stat.isFile()) {
- file = brotliFile;
- serve(stat);
- } else if (shouldTryGzip) {
- tryServeWithGzip();
- } else {
- statFile();
- }
- });
- } catch (err) {
- status[500](res, next, { error: err.message });
- }
- }
- const shouldTryBrotli = opts.brotli && shouldCompressBrotli(req);
- const shouldTryGzip = opts.gzip && shouldCompressGzip(req);
- // always try brotli first, next try gzip, finally serve without compression
- if (shouldTryBrotli) {
- tryServeWithBrotli(shouldTryGzip);
- } else if (shouldTryGzip) {
- tryServeWithGzip();
- } else {
- statFile();
- }
- };
- };
- httpServerCore = module.exports;
- httpServerCore.version = version;
- httpServerCore.showDir = showDir;
|