var fs = require('fs') , events = require('events') , buffer = require('buffer') , http = require('http') , url = require('url') , path = require('path') , mime = require('mime') , util = require('./node-static/util'); // Current version var version = [0, 7, 9]; var Server = function (root, options) { if (root && (typeof(root) === 'object')) { options = root; root = null } // resolve() doesn't normalize (to lowercase) drive letters on Windows this.root = path.normalize(path.resolve(root || '.')); this.options = options || {}; this.cache = 3600; this.defaultHeaders = {}; this.options.headers = this.options.headers || {}; this.options.indexFile = this.options.indexFile || "index.html"; if ('cache' in this.options) { if (typeof(this.options.cache) === 'number') { this.cache = this.options.cache; } else if (! this.options.cache) { this.cache = false; } } if ('serverInfo' in this.options) { this.serverInfo = this.options.serverInfo.toString(); } else { this.serverInfo = 'node-static/' + version.join('.'); } this.defaultHeaders['server'] = this.serverInfo; if (this.cache !== false) { this.defaultHeaders['cache-control'] = 'max-age=' + this.cache; } for (var k in this.defaultHeaders) { this.options.headers[k] = this.options.headers[k] || this.defaultHeaders[k]; } }; Server.prototype.serveDir = function (pathname, req, res, finish) { var htmlIndex = path.join(pathname, this.options.indexFile), that = this; fs.stat(htmlIndex, function (e, stat) { if (!e) { var status = 200; var headers = {}; var originalPathname = decodeURI(url.parse(req.url).pathname); if (originalPathname.length && originalPathname.charAt(originalPathname.length - 1) !== '/') { return finish(301, { 'Location': originalPathname + '/' }); } else { that.respond(null, status, headers, [htmlIndex], stat, req, res, finish); } } else { // Stream a directory of files as a single file. fs.readFile(path.join(pathname, 'index.json'), function (e, contents) { if (e) { return finish(404, {}) } var index = JSON.parse(contents); streamFiles(index.files); }); } }); function streamFiles(files) { util.mstat(pathname, files, function (e, stat) { if (e) { return finish(404, {}) } that.respond(pathname, 200, {}, files, stat, req, res, finish); }); } }; Server.prototype.serveFile = function (pathname, status, headers, req, res) { var that = this; var promise = new(events.EventEmitter); pathname = this.resolve(pathname); fs.stat(pathname, function (e, stat) { if (e) { return promise.emit('error', e); } that.respond(null, status, headers, [pathname], stat, req, res, function (status, headers) { that.finish(status, headers, req, res, promise); }); }); return promise; }; Server.prototype.finish = function (status, headers, req, res, promise, callback) { var result = { status: status, headers: headers, message: http.STATUS_CODES[status] }; headers['server'] = this.serverInfo; if (!status || status >= 400) { if (callback) { callback(result); } else { if (promise.listeners('error').length > 0) { promise.emit('error', result); } else { res.writeHead(status, headers); res.end(); } } } else { // Don't end the request here, if we're streaming; // it's taken care of in `prototype.stream`. if (status !== 200 || req.method !== 'GET') { res.writeHead(status, headers); res.end(); } callback && callback(null, result); promise.emit('success', result); } }; Server.prototype.servePath = function (pathname, status, headers, req, res, finish) { var that = this, promise = new(events.EventEmitter); pathname = this.resolve(pathname); // Make sure we're not trying to access a // file outside of the root. if (pathname.indexOf(that.root) === 0) { fs.stat(pathname, function (e, stat) { if (e) { finish(404, {}); } else if (stat.isFile()) { // Stream a single file. that.respond(null, status, headers, [pathname], stat, req, res, finish); } else if (stat.isDirectory()) { // Stream a directory of files. that.serveDir(pathname, req, res, finish); } else { finish(400, {}); } }); } else { // Forbidden finish(403, {}); } return promise; }; Server.prototype.resolve = function (pathname) { return path.resolve(path.join(this.root, pathname)); }; Server.prototype.serve = function (req, res, callback) { var that = this, promise = new(events.EventEmitter), pathname; var finish = function (status, headers) { that.finish(status, headers, req, res, promise, callback); }; try { pathname = decodeURI(url.parse(req.url).pathname); } catch(e) { return process.nextTick(function() { return finish(400, {}); }); } process.nextTick(function () { that.servePath(pathname, 200, {}, req, res, finish).on('success', function (result) { promise.emit('success', result); }).on('error', function (err) { promise.emit('error'); }); }); if (! callback) { return promise } }; /* Check if we should consider sending a gzip version of the file based on the * file content type and client's Accept-Encoding header value. */ Server.prototype.gzipOk = function (req, contentType) { var enable = this.options.gzip; if(enable && (typeof enable === 'boolean' || (contentType && (enable instanceof RegExp) && enable.test(contentType)))) { var acceptEncoding = req.headers['accept-encoding']; return acceptEncoding && acceptEncoding.indexOf("gzip") >= 0; } return false; } /* Send a gzipped version of the file if the options and the client indicate gzip is enabled and * we find a .gz file mathing the static resource requested. */ Server.prototype.respondGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) { var that = this; if (files.length == 1 && this.gzipOk(req, contentType)) { var gzFile = files[0] + ".gz"; fs.stat(gzFile, function (e, gzStat) { if (!e && gzStat.isFile()) { var vary = _headers['Vary']; _headers['Vary'] = (vary && vary != 'Accept-Encoding' ? vary + ', ' : '') + 'Accept-Encoding'; _headers['Content-Encoding'] = 'gzip'; stat.size = gzStat.size; files = [gzFile]; } that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); }); } else { // Client doesn't want gzip or we're sending multiple files that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); } } Server.prototype.parseByteRange = function (req, stat) { var byteRange = { from: 0, to: 0, valid: false } var rangeHeader = req.headers['range']; var flavor = 'bytes='; if (rangeHeader) { if (rangeHeader.indexOf(flavor) == 0 && rangeHeader.indexOf(',') == -1) { /* Parse */ rangeHeader = rangeHeader.substr(flavor.length).split('-'); byteRange.from = parseInt(rangeHeader[0]); byteRange.to = parseInt(rangeHeader[1]); /* Replace empty fields of differential requests by absolute values */ if (isNaN(byteRange.from) && !isNaN(byteRange.to)) { byteRange.from = stat.size - byteRange.to; byteRange.to = stat.size ? stat.size - 1 : 0; } else if (!isNaN(byteRange.from) && isNaN(byteRange.to)) { byteRange.to = stat.size ? stat.size - 1 : 0; } /* General byte range validation */ if (!isNaN(byteRange.from) && !!byteRange.to && 0 <= byteRange.from && byteRange.from < byteRange.to) { byteRange.valid = true; } else { console.warn("Request contains invalid range header: ", rangeHeader); } } else { console.warn("Request contains unsupported range header: ", rangeHeader); } } return byteRange; } Server.prototype.respondNoGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) { var mtime = Date.parse(stat.mtime), key = pathname || files[0], headers = {}, clientETag = req.headers['if-none-match'], clientMTime = Date.parse(req.headers['if-modified-since']), startByte = 0, length = stat.size, byteRange = this.parseByteRange(req, stat); /* Handle byte ranges */ if (files.length == 1 && byteRange.valid) { if (byteRange.to < length) { // Note: HTTP Range param is inclusive startByte = byteRange.from; length = byteRange.to - byteRange.from + 1; status = 206; // Set Content-Range response header (we advertise initial resource size on server here (stat.size)) headers['Content-Range'] = 'bytes ' + byteRange.from + '-' + byteRange.to + '/' + stat.size; } else { byteRange.valid = false; console.warn("Range request exceeds file boundaries, goes until byte no", byteRange.to, "against file size of", length, "bytes"); } } /* In any case, check for unhandled byte range headers */ if (!byteRange.valid && req.headers['range']) { console.error(new Error("Range request present but invalid, might serve whole file instead")); } // Copy default headers for (var k in this.options.headers) { headers[k] = this.options.headers[k] } // Copy custom headers for (var k in _headers) { headers[k] = _headers[k] } headers['Etag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-')); headers['Date'] = new(Date)().toUTCString(); headers['Last-Modified'] = new(Date)(stat.mtime).toUTCString(); headers['Content-Type'] = contentType; headers['Content-Length'] = length; for (var k in _headers) { headers[k] = _headers[k] } // Conditional GET // If the "If-Modified-Since" or "If-None-Match" headers // match the conditions, send a 304 Not Modified. if ((clientMTime || clientETag) && (!clientETag || clientETag === headers['Etag']) && (!clientMTime || clientMTime >= mtime)) { // 304 response should not contain entity headers ['Content-Encoding', 'Content-Language', 'Content-Length', 'Content-Location', 'Content-MD5', 'Content-Range', 'Content-Type', 'Expires', 'Last-Modified'].forEach(function (entityHeader) { delete headers[entityHeader]; }); finish(304, headers); } else { res.writeHead(status, headers); this.stream(key, files, length, startByte, res, function (e) { if (e) { return finish(500, {}) } finish(status, headers); }); } }; Server.prototype.respond = function (pathname, status, _headers, files, stat, req, res, finish) { var contentType = _headers['Content-Type'] || mime.lookup(files[0]) || 'application/octet-stream'; if(this.options.gzip) { this.respondGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); } else { this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish); } } Server.prototype.stream = function (pathname, files, length, startByte, res, callback) { (function streamFile(files, offset) { var file = files.shift(); if (file) { file = path.resolve(file) === path.normalize(file) ? file : path.join(pathname || '.', file); // Stream the file to the client fs.createReadStream(file, { flags: 'r', mode: 0666, start: startByte, end: startByte + (length ? length - 1 : 0) }).on('data', function (chunk) { // Bounds check the incoming chunk and offset, as copying // a buffer from an invalid offset will throw an error and crash if (chunk.length && offset < length && offset >= 0) { offset += chunk.length; } }).on('close', function () { streamFile(files, offset); }).on('error', function (err) { callback(err); console.error(err); }).pipe(res, { end: false }); } else { res.end(); callback(null, offset); } })(files.slice(0), 0); }; // Exports exports.Server = Server; exports.version = version; exports.mime = mime;