#! /usr/bin/env node var path = require('path'), fs = require('fs'), url = require('url'), mime = require('mime'), urlJoin = require('url-join'), showDir = require('./ecstatic/showdir'), version = JSON.parse( fs.readFileSync(__dirname + '/../package.json').toString() ).version, status = require('./ecstatic/status-handlers'), generateEtag = require('./ecstatic/etag'), optsParser = require('./ecstatic/opts'); var ecstatic = module.exports = function (dir, options) { if (typeof dir !== 'string') { options = dir; dir = options.root; } var root = path.join(path.resolve(dir), '/'), opts = optsParser(options), cache = opts.cache, autoIndex = opts.autoIndex, baseDir = opts.baseDir, defaultExt = opts.defaultExt, handleError = opts.handleError, headers = opts.headers, serverHeader = opts.serverHeader, weakEtags = opts.weakEtags, 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) {} if (typeof opts.mimeTypes === 'string') { mime.load(opts.mimeTypes); } else if (typeof opts.mimeTypes === 'object') { mime.define(opts.mimeTypes); } } return function middleware (req, res, next) { // Strip any null bytes from the url while(req.url.indexOf('%00') !== -1) { req.url = req.url.replace(/\%00/g, ''); } // Figure out the path for the file from the given url var parsed = url.parse(req.url); try { decodeURIComponent(req.url); // check validity of url var pathname = decodePathname(parsed.pathname); } catch (err) { return status[400](res, next, { error: err }); } var file = path.normalize( path.join(root, path.relative( path.join('/', baseDir), pathname ) ) ), gzipped = file + '.gz'; if(serverHeader !== false) { // Set common headers. res.setHeader('server', 'ecstatic-'+version); } Object.keys(headers).forEach(function (key) { res.setHeader(key, headers[key]) }) if (req.method === 'OPTIONS' && handleOptionsMethod) { return res.end(); } // TODO: This check is broken, which causes the 403 on the // expected 404. if (file.slice(0, root.length) !== root) { return status[403](res, next); } if (req.method && (req.method !== 'GET' && req.method !== 'HEAD' )) { return status[405](res, next); } function statFile() { fs.stat(file, function (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:'') }, res, next); } else { // Try to serve default ./404.html middleware({ url: (handleError ? ('/' + path.join(baseDir, '404.' + defaultExt)) : req.url), statusCode: 404 }, res, next); } } else if (err) { status[500](res, next, { error: err }); } else if (stat.isDirectory()) { // 302 to / if necessary if (!parsed.pathname.match(/\/$/)) { res.statusCode = 302; res.setHeader('location', parsed.pathname + '/' + (parsed.query? ('?' + parsed.query):'') ); return res.end(); } if (autoIndex) { return middleware({ url: urlJoin(encodeURIComponent(pathname), '/index.' + defaultExt) }, res, function (err) { if (err) { return status[500](res, next, { error: err }); } if (opts.showDir) { return showDir(opts, stat)(req, res); } return status[403](res, next); }); } if (opts.showDir) { return showDir(opts, stat)(req, res); } status[404](res, next); } else { serve(stat); } }); } // Look for a gzipped file if this is turned on if (opts.gzip && shouldCompress(req)) { fs.stat(gzipped, function (err, stat) { if (!err && stat.isFile()) { file = gzipped; return serve(stat); } else { statFile(); } }); } else { statFile(); } function serve(stat) { // Do a MIME lookup, fall back to octet-stream and handle gzip // special case. var defaultType = opts.contentType || 'application/octet-stream', contentType = mime.lookup(file, defaultType), charSet; if (contentType) { charSet = mime.charsets.lookup(contentType, 'utf-8'); if (charSet) { contentType += '; charset=' + charSet; } } if (path.extname(file) === '.gz') { res.setHeader('Content-Encoding', 'gzip'); // strip gz ending and lookup mime type contentType = mime.lookup(path.basename(file, ".gz"), defaultType); } var range = (req.headers && req.headers['range']); if (range) { var total = stat.size; var parts = range.replace(/bytes=/, "").split("-"); var partialstart = parts[0]; var partialend = parts[1]; var start = parseInt(partialstart, 10); var end = Math.min(total-1, partialend ? parseInt(partialend, 10) : total-1); var chunksize = (end-start)+1; if (start > end || isNaN(start) || isNaN(end)) { return status['416'](res, next); } var fstream = fs.createReadStream(file, {start: start, end: end}); fstream.on('error', function (err) { status['500'](res, next, { error: err }); }); res.on('close', function () { fstream.destroy(); }); res.writeHead(206, { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': contentType }); fstream.pipe(res); return; } // TODO: Helper for this, with default headers. var lastModified = (new Date(stat.mtime)).toUTCString(), etag = generateEtag(stat, weakEtags); res.setHeader('last-modified', lastModified); res.setHeader('etag', etag); if (typeof cache === 'function') { var requestSpecificCache = cache(pathname); if (typeof requestSpecificCache === 'number') { requestSpecificCache = 'max-age=' + requestSpecificCache; } res.setHeader('cache-control', requestSpecificCache); } else { res.setHeader('cache-control', cache); } // Return a 304 if necessary if (shouldReturn304(req, lastModified, etag)) { return status[304](res, next); } 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") { return res.end(); } var stream = fs.createReadStream(file); stream.pipe(res); stream.on('error', function (err) { status['500'](res, next, { error: err }); }); } function shouldReturn304(req, serverLastModified, serverEtag) { if (!req || !req.headers) { return false; } var clientModifiedSince = req.headers['if-modified-since'], clientEtag = req.headers['if-none-match']; 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 { var 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; } }; }; ecstatic.version = version; ecstatic.showDir = showDir; // Check to see if we should try to compress a file with gzip. function shouldCompress(req) { var headers = req.headers; return headers && headers['accept-encoding'] && headers['accept-encoding'] .split(",") .some(function (el) { return ['*','compress', 'gzip', 'deflate'].indexOf(el) != -1; }) ; } // See: https://github.com/jesusabdullah/node-ecstatic/issues/109 function decodePathname(pathname) { var pieces = pathname.replace(/\\/g,"/").split('/'); return pieces.map(function (piece) { piece = decodeURIComponent(piece); if (process.platform === 'win32' && /\\/.test(piece)) { throw new Error('Invalid forward slash character'); } return piece; }).join('/'); } if (!module.parent) { var defaults = require('./ecstatic/defaults.json') var http = require('http'), opts = require('minimist')(process.argv.slice(2), { alias: require('./ecstatic/aliases.json'), default: defaults, boolean: Object.keys(defaults).filter(function (key) { return typeof defaults[key] === 'boolean' }) }), envPORT = parseInt(process.env.PORT, 10), port = envPORT > 1024 && envPORT <= 65536 ? envPORT : opts.port || opts.p || 8000, dir = opts.root || opts._[0] || process.cwd(); if (opts.help || opts.h) { var u = console.error; u('usage: ecstatic [dir] {options} --port PORT'); u('see https://npm.im/ecstatic for more docs'); return; } http.createServer(ecstatic(dir, opts)) .listen(port, function () { console.log('ecstatic serving ' + dir + ' at http://0.0.0.0:' + port); }); }