394 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			394 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
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;
 | 
						|
 | 
						|
 | 
						|
 |