#! /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;