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; | ||
|  | 
 | ||
|  | 
 | ||
|  | 
 |