You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
602 lines
16 KiB
602 lines
16 KiB
module.exports = st |
|
|
|
st.Mount = Mount |
|
|
|
var mime = require('mime') |
|
var path = require('path') |
|
var fs |
|
try { |
|
fs = require('graceful-fs') |
|
} catch (e) { |
|
fs = require('fs') |
|
} |
|
var url = require('url') |
|
var zlib = require('zlib') |
|
var Neg = require('negotiator') |
|
var http = require('http') |
|
var AC = require('async-cache') |
|
var util = require('util') |
|
var FD = require('fd') |
|
var bl = require('bl') |
|
|
|
// default caching options |
|
var defaultCacheOptions = { |
|
fd: { |
|
max: 1000, |
|
maxAge: 1000 * 60 * 60, |
|
}, |
|
stat: { |
|
max: 5000, |
|
maxAge: 1000 * 60 |
|
}, |
|
content: { |
|
max: 1024 * 1024 * 64, |
|
length: function (n) { |
|
return n.length |
|
}, |
|
maxAge: 1000 * 60 * 10 |
|
}, |
|
index: { |
|
max: 1024 * 8, |
|
length: function (n) { |
|
return n.length |
|
}, |
|
maxAge: 1000 * 60 * 10 |
|
}, |
|
readdir: { |
|
max: 1000, |
|
length: function (n) { |
|
return n.length |
|
}, |
|
maxAge: 1000 * 60 * 10 |
|
} |
|
} |
|
|
|
function st (opt) { |
|
var p, u |
|
if (typeof opt === 'string') { |
|
p = opt |
|
opt = arguments[1] |
|
if (typeof opt === 'string') { |
|
u = opt |
|
opt = arguments[2] |
|
} |
|
} |
|
|
|
if (!opt) opt = {} |
|
else opt = util._extend({}, opt) |
|
|
|
if (!p) p = opt.path |
|
if (typeof p !== 'string') throw new Error('no path specified') |
|
p = path.resolve(p) |
|
if (!u) u = opt.url |
|
if (!u) u = '' |
|
if (u.charAt(0) !== '/') u = '/' + u |
|
|
|
opt.url = u |
|
opt.path = p |
|
|
|
var m = new Mount(opt) |
|
var fn = m.serve.bind(m) |
|
fn._this = m |
|
return fn |
|
} |
|
|
|
function Mount (opt) { |
|
if (!opt) throw new Error('no options provided') |
|
if (typeof opt !== 'object') throw new Error('invalid options') |
|
if (!(this instanceof Mount)) return new Mount(opt) |
|
|
|
this.opt = opt |
|
this.url = opt.url |
|
this.path = opt.path |
|
this._index = opt.index === false ? false |
|
: typeof opt.index === 'string' ? opt.index |
|
: true |
|
this.fdman = FD() |
|
|
|
// cache basically everything |
|
var c = this.getCacheOptions(opt) |
|
this.cache = { |
|
fd: AC(c.fd), |
|
stat: AC(c.stat), |
|
index: AC(c.index), |
|
readdir: AC(c.readdir), |
|
content: AC(c.content) |
|
} |
|
|
|
this._cacheControl = |
|
c.content.maxAge === false |
|
? undefined |
|
: typeof c.content.cacheControl == 'string' |
|
? c.content.cacheControl |
|
: opt.cache === false |
|
? 'no-cache' |
|
: 'public, max-age=' + (c.content.maxAge / 1000) |
|
} |
|
|
|
// lru-cache doesn't like when max=0, so we just pretend |
|
// everything is really big. kind of a kludge, but easiest way |
|
// to get it done |
|
var none = { max: 1, length: function() { |
|
return Infinity |
|
}} |
|
var noCaching = { |
|
fd: none, |
|
stat: none, |
|
index: none, |
|
readdir: none, |
|
content: none |
|
} |
|
|
|
Mount.prototype.getCacheOptions = function (opt) { |
|
var o = opt.cache |
|
, set = function (key) { |
|
return o[key] === false |
|
? util._extend({}, none) |
|
: util._extend(util._extend({}, d[key]), o[key]) |
|
} |
|
|
|
if (o === false) |
|
o = noCaching |
|
else if (!o) |
|
o = {} |
|
|
|
var d = defaultCacheOptions |
|
|
|
// should really only ever set max and maxAge here. |
|
// load and fd disposal is important to control. |
|
var c = { |
|
fd: set('fd'), |
|
stat: set('stat'), |
|
index: set('index'), |
|
readdir: set('readdir'), |
|
content: set('content'), |
|
} |
|
|
|
c.fd.dispose = this.fdman.close.bind(this.fdman) |
|
c.fd.load = this.fdman.open.bind(this.fdman) |
|
|
|
c.stat.load = this._loadStat.bind(this) |
|
c.index.load = this._loadIndex.bind(this) |
|
c.readdir.load = this._loadReaddir.bind(this) |
|
c.content.load = this._loadContent.bind(this) |
|
return c |
|
} |
|
|
|
// get the path component from a URI |
|
Mount.prototype.getUriPath = function (u) { |
|
var p = url.parse(u).pathname |
|
|
|
// Encoded dots are dots |
|
p = p.replace(/%2e/ig, '.') |
|
|
|
// encoded slashes are / |
|
p = p.replace(/%2f|%5c/ig, '/') |
|
|
|
// back slashes are slashes |
|
p = p.replace(/[\/\\]/g, '/') |
|
|
|
// Make sure it starts with a slash |
|
p = p.replace(/^\//, '/') |
|
if ((/[\/\\]\.\.([\/\\]|$)/).test(p)) { |
|
// traversal urls not ever even slightly allowed. clearly shenanigans |
|
// send a 403 on that noise, do not pass go, do not collect $200 |
|
return 403 |
|
} |
|
|
|
u = path.normalize(p).replace(/\\/g, '/') |
|
if (u.indexOf(this.url) !== 0) return false |
|
|
|
try { |
|
u = decodeURIComponent(u) |
|
} |
|
catch (e) { |
|
// if decodeURIComponent failed, we weren't given a valid URL to begin with. |
|
return false |
|
} |
|
|
|
// /a/b/c mounted on /path/to/z/d/x |
|
// /a/b/c/d --> /path/to/z/d/x/d |
|
u = u.substr(this.url.length) |
|
if (u.charAt(0) !== '/') u = '/' + u |
|
|
|
return u |
|
} |
|
|
|
// get a path from a url |
|
Mount.prototype.getPath = function (u) { |
|
return path.join(this.path, u) |
|
} |
|
|
|
// get a url from a path |
|
Mount.prototype.getUrl = function (p) { |
|
p = path.resolve(p) |
|
if (p.indexOf(this.path) !== 0) return false |
|
p = path.join('/', p.substr(this.path.length)) |
|
var u = path.join(this.url, p).replace(/\\/g, '/') |
|
return u |
|
} |
|
|
|
Mount.prototype.serve = function (req, res, next) { |
|
if (req.method !== 'HEAD' && req.method !== 'GET') { |
|
if (typeof next === 'function') next() |
|
return false |
|
} |
|
|
|
// querystrings are of no concern to us |
|
if (!req.sturl) |
|
req.sturl = this.getUriPath(req.url) |
|
|
|
// don't allow dot-urls by default, unless explicitly allowed. |
|
// If we got a 403, then it's explicitly forbidden. |
|
if (req.sturl === 403 || (!this.opt.dot && (/(^|\/)\./).test(req.sturl))) { |
|
res.statusCode = 403 |
|
res.end('Forbidden') |
|
return true |
|
} |
|
|
|
// Falsey here means we got some kind of invalid path. |
|
// Probably urlencoding we couldn't understand, or some |
|
// other "not compatible with st, but maybe ok" thing. |
|
if (typeof req.sturl !== 'string' || req.sturl == '') { |
|
if (typeof next === 'function') next() |
|
return false |
|
} |
|
|
|
var p = this.getPath(req.sturl) |
|
|
|
// now we have a path. check for the fd. |
|
this.cache.fd.get(p, function (er, fd) { |
|
// inability to open is some kind of error, probably 404 |
|
// if we're in passthrough, AND got a next function, we can |
|
// fall through to that. otherwise, we already returned true, |
|
// send an error. |
|
if (er) { |
|
if (this.opt.passthrough === true && er.code === 'ENOENT' && next) |
|
return next() |
|
return this.error(er, res) |
|
} |
|
|
|
// we may be about to use this, so don't let it be closed by cache purge |
|
this.fdman.checkout(p, fd) |
|
// a safe end() function that can be called multiple times but |
|
// only perform a single checkin |
|
var end = this.fdman.checkinfn(p, fd) |
|
|
|
this.cache.stat.get(fd+':'+p, function (er, stat) { |
|
if (er) { |
|
if (next && this.opt.passthrough === true && this._index === false) { |
|
return next() |
|
} |
|
end() |
|
return this.error(er, res) |
|
} |
|
|
|
var isDirectory = stat.isDirectory() |
|
|
|
if (isDirectory) { |
|
end() // we won't need this fd for a directory in any case |
|
if (next && this.opt.passthrough === true && this._index === false) { |
|
// this is done before if-modified-since and if-non-match checks so |
|
// cached modified and etag values won't return 304's if we've since |
|
// switched to !index. See Issue #51. |
|
return next() |
|
} |
|
} |
|
|
|
var ims = req.headers['if-modified-since'] |
|
if (ims) ims = new Date(ims).getTime() |
|
if (ims && ims >= stat.mtime.getTime()) { |
|
res.statusCode = 304 |
|
res.end() |
|
return end() |
|
} |
|
|
|
var etag = getEtag(stat) |
|
if (req.headers['if-none-match'] === etag) { |
|
res.statusCode = 304 |
|
res.end() |
|
return end() |
|
} |
|
|
|
// only set headers once we're sure we'll be serving this request |
|
if (!res.getHeader('cache-control') && this._cacheControl) |
|
res.setHeader('cache-control', this._cacheControl) |
|
res.setHeader('last-modified', stat.mtime.toUTCString()) |
|
res.setHeader('etag', etag) |
|
|
|
if (this.opt.cors) { |
|
res.setHeader('Access-Control-Allow-Origin', '*') |
|
res.setHeader('Access-Control-Allow-Headers', |
|
'Origin, X-Requested-With, Content-Type, Accept, Range') |
|
} |
|
|
|
return isDirectory |
|
? this.index(p, req, res) |
|
: this.file(p, fd, stat, etag, req, res, end) |
|
}.bind(this)) |
|
}.bind(this)) |
|
|
|
return true |
|
} |
|
|
|
Mount.prototype.error = function (er, res) { |
|
res.statusCode = typeof er === 'number' ? er |
|
: er.code === 'ENOENT' || er.code === 'EISDIR' ? 404 |
|
: er.code === 'EPERM' || er.code === 'EACCES' ? 403 |
|
: 500 |
|
|
|
if (typeof res.error === 'function') { |
|
// pattern of express and ErrorPage |
|
return res.error(res.statusCode, er) |
|
} |
|
|
|
res.setHeader('content-type', 'text/plain') |
|
res.end(http.STATUS_CODES[res.statusCode] + '\n') |
|
} |
|
|
|
Mount.prototype.index = function (p, req, res) { |
|
if (this._index === true) { |
|
return this.autoindex(p, req, res) |
|
} |
|
if (typeof this._index === 'string') { |
|
if (!/\/$/.test(req.sturl)) req.sturl += '/' |
|
req.sturl += this._index |
|
return this.serve(req, res) |
|
} |
|
return this.error(404, res) |
|
} |
|
|
|
Mount.prototype.autoindex = function (p, req, res) { |
|
if (!/\/$/.exec(req.sturl)) { |
|
res.statusCode = 301 |
|
res.setHeader('location', req.sturl + '/') |
|
res.end('Moved: ' + req.sturl + '/') |
|
return |
|
} |
|
|
|
this.cache.index.get(p, function (er, html) { |
|
if (er) return this.error(er, res) |
|
|
|
res.statusCode = 200 |
|
res.setHeader('content-type', 'text/html') |
|
res.setHeader('content-length', html.length) |
|
res.end(html) |
|
}.bind(this)) |
|
} |
|
|
|
|
|
Mount.prototype.file = function (p, fd, stat, etag, req, res, end) { |
|
var key = stat.size + ':' + etag |
|
|
|
var mt = mime.lookup(path.extname(p)) |
|
if (mt !== 'application/octet-stream') { |
|
res.setHeader('content-type', mt) |
|
} |
|
|
|
// only use the content cache if it will actually fit there. |
|
if (this.cache.content.has(key)) { |
|
end() |
|
this.cachedFile(p, stat, etag, req, res) |
|
} else { |
|
this.streamFile(p, fd, stat, etag, req, res, end) |
|
} |
|
} |
|
|
|
Mount.prototype.cachedFile = function (p, stat, etag, req, res) { |
|
var key = stat.size + ':' + etag |
|
var gz = this.opt.gzip !== false && getGz(p, req) |
|
|
|
this.cache.content.get(key, function (er, content) { |
|
if (er) return this.error(er, res) |
|
res.statusCode = 200 |
|
if (this.opt.cachedHeader) |
|
res.setHeader('x-from-cache', 'true') |
|
if (gz && content.gz) { |
|
res.setHeader('content-encoding', 'gzip') |
|
res.setHeader('content-length', content.gz.length) |
|
res.end(content.gz) |
|
} else { |
|
res.setHeader('content-length', content.length) |
|
res.end(content) |
|
} |
|
}.bind(this)) |
|
} |
|
|
|
Mount.prototype.streamFile = function (p, fd, stat, etag, req, res, end) { |
|
var streamOpt = { fd: fd, start: 0, end: stat.size } |
|
var stream = fs.createReadStream(p, streamOpt) |
|
stream.destroy = function () {} |
|
|
|
// gzip only if not explicitly turned off or client doesn't accept it |
|
var gzOpt = this.opt.gzip !== false |
|
var gz = gzOpt && getGz(p, req) |
|
var cachable = this.cache.content._cache.max > stat.size |
|
var gzstr |
|
|
|
// need a gzipped version for the cache, so do it regardless of what the client wants |
|
if (gz || (gzOpt && cachable)) gzstr = zlib.Gzip() |
|
|
|
// too late to effectively handle any errors. |
|
// just kill the connection if that happens. |
|
stream.on('error', function(e) { |
|
console.error('Error serving %s fd=%d\n%s', p, fd, e.stack || e.message) |
|
res.socket.destroy() |
|
end() |
|
}) |
|
|
|
if (res.filter) stream = stream.pipe(res.filter) |
|
|
|
res.statusCode = 200 |
|
|
|
if (gz) { |
|
// we don't know how long it'll be, since it will be compressed. |
|
res.setHeader('content-encoding', 'gzip') |
|
stream.pipe(gzstr).pipe(res) |
|
} else { |
|
if (!res.filter) res.setHeader('content-length', stat.size) |
|
stream.pipe(res) |
|
if (gzstr) |
|
stream.pipe(gzstr) // for cache |
|
} |
|
|
|
stream.on('end', function () { |
|
process.nextTick(end) |
|
}) |
|
|
|
if (cachable) { |
|
// collect it, and put it in the cache |
|
|
|
var calls = 0 |
|
|
|
// called by bl() for both the raw stream and gzipped stream if we're |
|
// caching gzipped data |
|
var collectEnd = function () { |
|
if (++calls == (gzOpt ? 2 : 1)) { |
|
var content = bufs.slice() |
|
content.gz = gzbufs && gzbufs.slice() |
|
this.cache.content.set(key, content) |
|
} |
|
}.bind(this) |
|
|
|
var key = stat.size + ':' + etag |
|
var bufs = bl(collectEnd) |
|
var gzbufs |
|
|
|
stream.pipe(bufs) |
|
|
|
if (gzstr) { |
|
gzbufs = bl(collectEnd) |
|
gzstr.pipe(gzbufs) |
|
} |
|
} |
|
} |
|
|
|
|
|
// cache-fillers |
|
|
|
Mount.prototype._loadIndex = function (p, cb) { |
|
// truncate off the first bits |
|
var url = p.substr(this.path.length).replace(/\\/g, '/') |
|
var t = url |
|
.replace(/"/g, '"') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/'/g, ''') |
|
|
|
var str = |
|
'<!doctype html>' + |
|
'<html>' + |
|
'<head><title>Index of ' + t + '</title></head>' + |
|
'<body>' + |
|
'<h1>Index of ' + t + '</h1>' + |
|
'<hr><pre><a href="../">../</a>\n' |
|
|
|
this.cache.readdir.get(p, function (er, data) { |
|
if (er) return cb(er) |
|
|
|
var nameLen = 0 |
|
var sizeLen = 0 |
|
|
|
Object.keys(data).map(function (f) { |
|
var d = data[f] |
|
|
|
var name = f |
|
.replace(/"/g, '"') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/'/g, ''') |
|
|
|
if (d.size === '-') name += '/' |
|
var showName = name.replace(/^(.{40}).{3,}$/, '$1..>') |
|
var linkName = encodeURIComponent(name) |
|
.replace(/%2e/ig, '.') // Encoded dots are dots |
|
.replace(/%2f|%5c/ig, '/') // encoded slashes are / |
|
.replace(/[\/\\]/g, '/') // back slashes are slashes |
|
|
|
nameLen = Math.max(nameLen, showName.length) |
|
sizeLen = Math.max(sizeLen, ('' + d.size).length) |
|
return [ '<a href="' + linkName + '">' + showName + '</a>', |
|
d.mtime, d.size, showName ] |
|
}).sort(function (a, b) { |
|
return a[2] === '-' && b[2] !== '-' ? -1 // dirs first |
|
: a[2] !== '-' && b[2] === '-' ? 1 |
|
: a[0].toLowerCase() < b[0].toLowerCase() ? -1 // then alpha |
|
: a[0].toLowerCase() > b[0].toLowerCase() ? 1 |
|
: 0 |
|
}).forEach(function (line) { |
|
var namePad = new Array(8 + nameLen - line[3].length).join(' ') |
|
var sizePad = new Array(8 + sizeLen - ('' + line[2]).length).join(' ') |
|
str += line[0] + namePad + |
|
line[1].toISOString() + |
|
sizePad + line[2] + '\n' |
|
}) |
|
|
|
str += '</pre><hr></body></html>' |
|
cb(null, new Buffer(str)) |
|
}) |
|
} |
|
|
|
Mount.prototype._loadReaddir = function (p, cb) { |
|
var len |
|
var data |
|
fs.readdir(p, function (er, files) { |
|
if (er) return cb(er) |
|
files = files.filter(function (f) { |
|
if (!this.opt.dot) return !/^\./.test(f) |
|
else return f !== '.' && f !== '..' |
|
}.bind(this)) |
|
len = files.length |
|
data = {} |
|
files.forEach(function (file) { |
|
var pf = path.join(p, file) |
|
this.cache.stat.get(pf, function (er, stat) { |
|
if (er) return cb(er) |
|
if (stat.isDirectory()) stat.size = '-' |
|
data[file] = stat |
|
next() |
|
}.bind(this)) |
|
}.bind(this)) |
|
}.bind(this)) |
|
|
|
function next () { |
|
if (--len === 0) cb(null, data) |
|
} |
|
} |
|
|
|
Mount.prototype._loadStat = function (key, cb) { |
|
// key is either fd:path or just a path |
|
var fdp = key.match(/^(\d+):(.*)/) |
|
if (fdp) { |
|
var fd = +fdp[1] |
|
var p = fdp[2] |
|
fs.fstat(fd, function (er, stat) { |
|
if (er) return cb(er) |
|
this.cache.stat.set(p, stat) |
|
cb(null, stat) |
|
}.bind(this)) |
|
} else { |
|
fs.stat(key, cb) |
|
} |
|
} |
|
|
|
Mount.prototype._loadContent = function () { |
|
// this function should never be called. |
|
// we check if the thing is in the cache, and if not, stream it in |
|
// manually. this.cache.content.get() should not ever happen. |
|
throw new Error('This should not ever happen') |
|
} |
|
|
|
function getEtag (s) { |
|
return '"' + s.dev + '-' + s.ino + '-' + s.mtime.getTime() + '"' |
|
} |
|
|
|
function getGz (p,req) { |
|
var gz = false |
|
if (!/\.t?gz$/.exec(p)) { |
|
var neg = req.negotiator || new Neg(req) |
|
gz = neg.preferredEncoding(['gzip', 'identity']) === 'gzip' |
|
} |
|
return gz |
|
}
|
|
|