ecstatic.js 11.3 KB
#! /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);
    });
}