blob: e382136b69f956158b7cc1986e558c5832ed8ba7 [file] [log] [blame]
#! /usr/bin/env node
'use strict';
const path = require('path');
const fs = require('fs');
const url = require('url');
const mime = require('mime');
const urlJoin = require('url-join');
const showDir = require('./ecstatic/show-dir');
const version = require('../package.json').version;
const status = require('./ecstatic/status-handlers');
const generateEtag = require('./ecstatic/etag');
const optsParser = require('./ecstatic/opts');
let ecstatic = null;
// See: https://github.com/jesusabdullah/node-ecstatic/issues/109
function decodePathname(pathname) {
const pieces = pathname.replace(/\\/g, '/').split('/');
return 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('/'));
}
// 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('');
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 serverHeader = opts.serverHeader;
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
// https://github.com/jfhbrook/node-ecstatic/pull/179
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;
} else 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;
// Strip any null bytes from the url
// This was at one point necessary because of an old bug in url.parse
//
// See: https://github.com/jfhbrook/node-ecstatic/issues/16#issuecomment-3039914
// See: https://github.com/jfhbrook/node-ecstatic/commit/43f7e72a31524f88f47e367c3cc3af710e67c9f4
//
// But this opens up a regex dos attack vector! D:
//
// Based on some research (ie asking #node-dev if this is still an issue),
// it's *probably* not an issue. :)
/*
while (req.url.indexOf('%00') !== -1) {
req.url = req.url.replace(/\%00/g, '');
}
*/
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`;
if (serverHeader !== false) {
// Set common headers.
res.setHeader('server', `ecstatic-${version}`);
}
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);
let charSet;
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) {
charSet = mime.charsets.lookup(contentType, 'utf-8');
if (charSet) {
contentType += `; charset=${charSet}`;
}
}
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 = fs.createReadStream(file);
stream.pipe(res);
stream.on('error', (err) => {
status['500'](res, next, { error: err });
});
}
function statFile() {
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
middleware({
url: (handleError ? `/${path.join(baseDir, `404.${defaultExt}`)}` : req.url),
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', `${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);
}
});
}
// serve gzip file if exists and is valid
function tryServeWithGzip() {
fs.stat(gzippedFile, (err, stat) => {
if (!err && stat.isFile()) {
hasGzipId12(gzippedFile, (gzipErr, isGzip) => {
if (!gzipErr && isGzip) {
file = gzippedFile;
serve(stat);
} else {
statFile();
}
});
} else {
statFile();
}
});
}
// serve brotli file if exists, otherwise try gzip
function tryServeWithBrotli(shouldTryGzip) {
fs.stat(brotliFile, (err, stat) => {
if (!err && stat.isFile()) {
file = brotliFile;
serve(stat);
} else if (shouldTryGzip) {
tryServeWithGzip();
} else {
statFile();
}
});
}
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();
}
};
};
ecstatic = module.exports;
ecstatic.version = version;
ecstatic.showDir = showDir;
if (!module.parent) {
/* eslint-disable global-require */
/* eslint-disable no-console */
const defaults = require('./ecstatic/defaults.json');
const http = require('http');
const minimist = require('minimist');
const aliases = require('./ecstatic/aliases.json');
const opts = minimist(process.argv.slice(2), {
alias: aliases,
default: defaults,
boolean: Object.keys(defaults).filter(
key => typeof defaults[key] === 'boolean'
),
});
const envPORT = parseInt(process.env.PORT, 10);
const port = envPORT > 1024 && envPORT <= 65536 ? envPORT : opts.port || opts.p || 8000;
const dir = opts.root || opts._[0] || process.cwd();
if (opts.help || opts.h) {
console.error('usage: ecstatic [dir] {options} --port PORT');
console.error('see https://npm.im/ecstatic for more docs');
} else {
http.createServer(ecstatic(dir, opts))
.listen(port, () => {
console.log(`ecstatic serving ${dir} at http://0.0.0.0:${port}`);
})
;
}
}