Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial commit

  • Loading branch information...
commit 347cb7c6900ed8e6c29d7e404f5b61c38f76f7b1 0 parents
TJ Holowaychuk tj authored
1  .gitignore
@@ -0,0 +1 @@
+node_modules
4 .npmignore
@@ -0,0 +1,4 @@
+support
+test
+examples
+*.sock
5 History.md
@@ -0,0 +1,5 @@
+
+0.0.1 / 2010-01-03
+==================
+
+ * Initial release
8 Makefile
@@ -0,0 +1,8 @@
+
+test:
+ @./node_modules/.bin/mocha \
+ --require should \
+ --reporter spec \
+ --bail
+
+.PHONY: test
40 Readme.md
@@ -0,0 +1,40 @@
+
+# send
+
+ Better streaming static file server with Range and conditional-GET support.
+
+## About
+
+ Send is Connect's `static()` extracted for generalized use, a secure file
+ server supporting partial responses (Ranges), conditional-GET negotiation, high test coverage, and emits
+ detailed errors which may be leveraged to take appropriate actions in your application or framework.
+
+ It does _not_ perform internal caching, you should use a reverse proxy cache such
+ as Varnish for this. If your application is small enough that it would benefit from single-node memory caching, it's small enough that it does not need caching at all ;).
+
+ FUD: If you're performing pointless benchmarks, before complaining first consider that node-static does not respect cache-control directives and thus responds faster, but with invalid responses, use a real cache.
+
+## License
+
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2  index.js
@@ -0,0 +1,2 @@
+
+module.exports = require('./lib/send');
440 lib/send.js
@@ -0,0 +1,440 @@
+
+/**
+ * Module dependencies.
+ */
+
+var debug = require('debug')('send')
+ , fs = require('fs')
+ , parseRange = require('range-parser')
+ , Stream = require('stream')
+ , mime = require('mime')
+ , fresh = require('fresh')
+ , path = require('path')
+ , basename = path.basename
+ , normalize = path.normalize
+ , join = path.join
+ , utils = require('./utils');
+
+/**
+ * Expose `send`.
+ */
+
+exports = module.exports = send;
+
+/**
+ * Expose mime module.
+ */
+
+exports.mime = mime;
+
+/**
+ * Return a `SendStream` for `path`.
+ *
+ * @param {String} path
+ * @return {SendStream}
+ * @api public
+ */
+
+function send(path) {
+ return new SendStream(path);
+}
+
+/**
+ * Initialize a `SendStream` with the given `path`.
+ *
+ * Events:
+ *
+ * - `error` an error occurred
+ * - `stream` file streaming has started
+ * - `end` streaming has completed
+ * - `directory` a directory was requested
+ * - `not modified` responded with 304
+ *
+ * @param {String} path
+ * @api private
+ */
+
+function SendStream(path) {
+ var self = this;
+ this.path = path;
+ this.maxage(0);
+ this.hidden(false);
+ this.index('index.html');
+}
+
+/**
+ * Inherits from `Stream.prototype`.
+ */
+
+SendStream.prototype.__proto__ = Stream.prototype;
+
+/**
+ * Enable or disable "hidden" (dot) files.
+ *
+ * @param {Boolean} path
+ * @return {SendStream}
+ * @api public
+ */
+
+SendStream.prototype.hidden = function(val){
+ debug('hidden %s', val);
+ this._hidden = val;
+ return this;
+};
+
+/**
+ * Set index `path`, set to a falsy
+ * value to disable index support.
+ *
+ * @param {String|Boolean} path
+ * @return {SendStream}
+ * @api public
+ */
+
+SendStream.prototype.index = function(path){
+ debug('index %s', path);
+ this._index = path;
+ return this;
+};
+
+/**
+ * Set root `path`.
+ *
+ * @param {String} path
+ * @return {SendStream}
+ * @api public
+ */
+
+SendStream.prototype.root = function(path){
+ this._root = path;
+ return this;
+};
+
+/**
+ * Set max-age to `ms`.
+ *
+ * @param {Number} ms
+ * @return {SendStream}
+ * @api public
+ */
+
+SendStream.prototype.maxage = function(ms){
+ if (Infinity == ms) ms = 60 * 60 * 24 * 365 * 1000;
+ debug('max-age %d', ms);
+ this._maxage = ms;
+ return this;
+};
+
+/**
+ * Emit error `status` and `msg`.
+ *
+ * @param {Number} status
+ * @param {String} msg
+ * @api private
+ */
+
+SendStream.prototype.error = function(status, msg){
+ var err = new Error(msg);
+ err.status = status;
+ this.emit('error', err);
+};
+
+/**
+ * Check if the pathname is potentially malicious.
+ *
+ * @return {Boolean}
+ * @api private
+ */
+
+SendStream.prototype.isMalicious = function(){
+ return !this._root && ~this.path.indexOf('..');
+};
+
+/**
+ * Check if the pathname ends with "/".
+ *
+ * @return {Boolean}
+ * @api private
+ */
+
+SendStream.prototype.hasTrailingSlash = function(){
+ return '/' == this.path[this.path.length - 1];
+};
+
+/**
+ * Check if the basename leads with ".".
+ *
+ * @return {Boolean}
+ * @api private
+ */
+
+SendStream.prototype.hasLeadingDot = function(){
+ return '.' == basename(this.path)[0];
+};
+
+/**
+ * Check if this is a conditional GET request.
+ *
+ * @return {Boolean}
+ * @api private
+ */
+
+SendStream.prototype.isConditionalGET = function(){
+ return this.req.headers['if-none-match']
+ || this.req.headers['if-modified-since'];
+};
+
+/**
+ * Strip content-* header fields.
+ *
+ * @api private
+ */
+
+SendStream.prototype.removeContentHeaderFields = function(){
+ var res = this.res;
+ Object.keys(res._headers).forEach(function(field){
+ if (0 == field.indexOf('content')) {
+ res.removeHeader(field);
+ }
+ });
+};
+
+/**
+ * Respond with 304 not modified.
+ *
+ * @api private
+ */
+
+SendStream.prototype.notModified = function(){
+ var res = this.res;
+ debug('not modified');
+ this.emit('not modified');
+ this.removeContentHeaderFields();
+ res.statusCode = 304;
+ res.end();
+};
+
+/**
+ * Check if the request is cacheable, aka
+ * responded with 2xx or 304 (see RFC 2616 section 14.2{5,6}).
+ *
+ * @return {Boolean}
+ * @api private
+ */
+
+SendStream.prototype.isCachable = function(){
+ var res = this.res;
+ return (res.statusCode >= 200 && res.statusCode < 300) || 304 == res.statusCode;
+};
+
+/**
+ * Handle stat() error.
+ *
+ * @param {Error} err
+ * @api private
+ */
+
+SendStream.prototype.onStatError = function(err){
+ var notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'];
+ if (~notfound.indexOf(err.code)) return this.error(404, 'not found');
+ this.error(500, err);
+};
+
+/**
+ * Check if the cache is fresh.
+ *
+ * @return {Boolean}
+ * @api private
+ */
+
+SendStream.prototype.isFresh = function(){
+ return fresh(this.req.headers, this.res._headers);
+};
+
+/**
+ * Pipe to `res.
+ *
+ * @param {Stream} res
+ * @return {Stream} res
+ * @api public
+ */
+
+SendStream.prototype.pipe = function(res){
+ var self = this
+ , args = arguments
+ , path = this.path
+ , root = this._root;
+
+ // references
+ this.res = res;
+ this.req = res.socket.parser.incoming; // TODO: wtf?
+
+ // invalid request uri
+ path = utils.decode(path);
+ if (-1 == path) return this.error(400, 'invalid request uri');
+
+ // null byte(s)
+ if (~path.indexOf('\0')) return this.error(400, 'invalid request uri');
+
+ // join / normalize from optional root dir
+ if (root) path = normalize(join(this._root, path));
+
+ // ".." is malicious without "root"
+ if (this.isMalicious()) return this.error(403, 'forbidden');
+
+ // malicious path
+ if (root && 0 != path.indexOf(root)) return this.error(403, 'forbidden');
+
+ // hidden file support
+ if (!this._hidden && this.hasLeadingDot()) return this.error(404, 'not found');
+
+ // index file support
+ if (this._index && this.hasTrailingSlash()) path += this._index;
+
+ debug('stat "%s"', path);
+ fs.stat(path, function(err, stat){
+ if (err) return self.onStatError(err);
+ if (stat.isDirectory()) return self.emit('directory', stat);
+ self.send(path, stat);
+ });
+
+ return res;
+};
+
+/**
+ * Transfer `path`.
+ *
+ * @param {String} path
+ * @api public
+ */
+
+SendStream.prototype.send = function(path, stat){
+ var options = {};
+ var len = stat.size;
+ var res = this.res;
+ var req = this.req;
+ var ranges = req.headers.range;
+
+ // set header fields
+ this.setHeader(stat);
+
+ // set content-type
+ this.type(path);
+
+ // conditional GET support
+ if (this.isConditionalGET()
+ && this.isCachable()
+ && this.isFresh()) {
+ return this.notModified();
+ }
+
+ // Range support
+ if (ranges) {
+ ranges = parseRange(len, ranges);
+
+ // unsatisfiable
+ if (-1 == ranges) {
+ res.setHeader('Content-Range', 'bytes */' + stat.size);
+ return this.error(416, 'requested range not satisfiable');
+ }
+
+ // valid (syntactically invalid ranges are treated as a regular response)
+ if (-2 != ranges) {
+ options.start = ranges[0].start;
+ options.end = ranges[0].end;
+
+ // Content-Range
+ len = options.end - options.start + 1;
+ res.statusCode = 206;
+ res.setHeader('Content-Range', 'bytes '
+ + options.start
+ + '-'
+ + options.end
+ + '/'
+ + stat.size);
+ }
+ }
+
+ // content-length
+ res.setHeader('Content-Length', len);
+
+ // HEAD support
+ if ('HEAD' == req.method) return res.end();
+
+ this.stream(path, options);
+};
+
+/**
+ * Stream `path` to the response.
+ *
+ * @param {String} path
+ * @param {Object} options
+ * @api private
+ */
+
+SendStream.prototype.stream = function(path, options){
+ var self = this;
+ var res = this.res;
+ var req = this.req;
+
+ // pipe
+ var stream = fs.createReadStream(path, options);
+ this.emit('stream', stream);
+ stream.pipe(res);
+
+ // socket closed, done with the fd
+ req.on('close', stream.destroy.bind(stream));
+
+ // error handling code-smell
+ stream.on('error', function(err){
+ // no hope in responding
+ if (res._header) {
+ console.error(err.stack);
+ req.destroy();
+ return;
+ }
+
+ // 500
+ err.status = 500;
+ self.emit('error', err);
+ });
+
+ // end
+ stream.on('end', function(){
+ self.emit('end');
+ });
+};
+
+/**
+ * Set content-type based on `path`
+ * if it hasn't been explicitly set.
+ *
+ * @param {String} path
+ * @api public
+ */
+
+SendStream.prototype.type = function(path){
+ var res = this.res;
+ if (res.getHeader('Content-Type')) return;
+ var type = mime.lookup(path);
+ var charset = mime.charsets.lookup(type);
+ debug('content-type %s', type);
+ res.setHeader('Content-Type', type + (charset ? '; charset=' + charset : ''));
+};
+
+/**
+ * Set reaponse header fields, most
+ * fields may be pre-defined.
+ *
+ * @param {Object} stat
+ * @api private
+ */
+
+SendStream.prototype.setHeader = function(stat){
+ var res = this.res;
+ res.setHeader('Accept-Ranges', 'bytes');
+ if (!res.getHeader('ETag')) res.setHeader('ETag', utils.etag(stat));
+ if (!res.getHeader('Date')) res.setHeader('Date', new Date().toUTCString());
+ if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', 'public, max-age=' + (this._maxage / 1000));
+ if (!res.getHeader('Last-Modified')) res.setHeader('Last-Modified', stat.mtime.toUTCString());
+};
47 lib/utils.js
@@ -0,0 +1,47 @@
+
+/**
+ * Return an ETag in the form of `"<size>-<mtime>"`
+ * from the given `stat`.
+ *
+ * @param {Object} stat
+ * @return {String}
+ * @api private
+ */
+
+exports.etag = function(stat) {
+ return '"' + stat.size + '-' + Number(stat.mtime) + '"';
+};
+
+/**
+ * decodeURIComponent.
+ *
+ * Allows V8 to only deoptimize this fn instead of all
+ * of send().
+ *
+ * @param {String} path
+ * @api private
+ */
+
+exports.decode = function(path){
+ try {
+ return decodeURIComponent(path);
+ } catch (err) {
+ return -1;
+ }
+};
+
+/**
+ * Escape the given string of `html`.
+ *
+ * @param {String} html
+ * @return {String}
+ * @api private
+ */
+
+exports.escape = function(html){
+ return String(html)
+ .replace(/&(?!\w+;)/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+};
19 package.json
@@ -0,0 +1,19 @@
+{
+ "name": "send",
+ "version": "0.0.1",
+ "description": "Better streaming static file server with Range and conditional-GET support",
+ "keywords": ["static", "file", "server"],
+ "author": "TJ Holowaychuk <tj@vision-media.ca>",
+ "dependencies": {
+ "debug": "*",
+ "mime": "1.2.6",
+ "fresh": "0.1.0",
+ "range-parser": "0.0.4"
+ },
+ "devDependencies": {
+ "mocha": "*",
+ "should": "*",
+ "supertest": "0.0.1"
+ },
+ "main": "index"
+}
1  test/fixtures/.hidden
@@ -0,0 +1 @@
+secret
1  test/fixtures/name.txt
@@ -0,0 +1 @@
+tobi
1  test/fixtures/nums
@@ -0,0 +1 @@
+123456789
3  test/fixtures/pets/index.html
@@ -0,0 +1,3 @@
+tobi
+loki
+jane
1  test/fixtures/some thing.txt
@@ -0,0 +1 @@
+hey
325 test/send.js
@@ -0,0 +1,325 @@
+
+var send = require('..')
+ , http = require('http')
+ , Stream = require('stream')
+ , request = require('supertest');
+
+var app = http.createServer(function(req, res){
+ function error(err) {
+ res.statusCode = err.status || 500;
+ res.end(err.message);
+ }
+
+ function redirect() {
+ res.statusCode = 301;
+ res.setHeader('Location', req.url + '/');
+ res.end('Redirecting to ' + req.url + '/');
+ }
+
+ send('test/fixtures' + req.url)
+ .on('error', error)
+ .on('directory', redirect)
+ .pipe(res);
+});
+
+describe('send(file).pipe(res)', function(){
+ it('should stream the file contents', function(done){
+ request(app)
+ .get('/name.txt')
+ .expect('Content-Length', '4')
+ .expect('tobi', done);
+ })
+
+ it('should decode the given path as a URI', function(done){
+ request(app)
+ .get('/some%20thing.txt')
+ .expect('hey', done);
+ })
+
+ it('should treat a malformed URI as a bad request', function(done){
+ request(app)
+ .get('/some%99thing.txt')
+ .expect('invalid request uri', done);
+ })
+
+ it('should treat an ENAMETOOLONG as a 404', function(done){
+ var path = Array(100).join('foobar');
+ request(app)
+ .get('/' + path)
+ .expect(404, done);
+ })
+
+ it('should support HEAD', function(done){
+ request(app)
+ .head('/name.txt')
+ .expect('Content-Length', '4')
+ .expect(200)
+ .end(function(err, res){
+ res.text.should.equal('');
+ done();
+ });
+ })
+
+ it('should add an ETag header field', function(done){
+ request(app)
+ .get('/name.txt')
+ .end(function(err, res){
+ if (err) return done(err);
+ res.headers.should.have.property('etag');
+ done();
+ });
+ })
+
+ it('should add a Date header field', function(done){
+ request(app)
+ .get('/name.txt')
+ .end(function(err, res){
+ if (err) return done(err);
+ res.headers.should.have.property('date');
+ done();
+ });
+ })
+
+ it('should add a Last-Modified header field', function(done){
+ request(app)
+ .get('/name.txt')
+ .end(function(err, res){
+ if (err) return done(err);
+ res.headers.should.have.property('last-modified');
+ done();
+ });
+ })
+
+ it('should add a Accept-Ranges header field', function(done){
+ request(app)
+ .get('/name.txt')
+ .expect('Accept-Ranges', 'bytes')
+ .end(done);
+ })
+
+ it('should 404 if the file does not exist', function(done){
+ request(app)
+ .get('/meow')
+ .expect(404)
+ .expect('not found')
+ .end(done);
+ })
+
+ it('should 301 if the directory exists', function(done){
+ request(app)
+ .get('/pets')
+ .expect(301)
+ .expect('Location', '/pets/')
+ .expect('Redirecting to /pets/')
+ .end(done);
+ })
+
+ describe('with conditional-GET', function(){
+ it('should respond with 304 on a match', function(done){
+ request(app)
+ .get('/name.txt')
+ .end(function(err, res){
+ var etag = res.headers.etag;
+
+ request(app)
+ .get('/name.txt')
+ .set('If-None-Match', etag)
+ .expect(304)
+ .end(function(err, res){
+ res.headers.should.not.have.property('content-type');
+ res.headers.should.not.have.property('content-length');
+ done();
+ });
+ })
+ })
+
+ it('should respond with 200 otherwise', function(done){
+ request(app)
+ .get('/name.txt')
+ .end(function(err, res){
+ var etag = res.headers.etag;
+
+ request(app)
+ .get('/name.txt')
+ .set('If-None-Match', '123')
+ .expect(200)
+ .expect('tobi')
+ .end(done);
+ })
+ })
+ })
+
+ describe('with Range request', function(){
+ it('should support byte ranges', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=0-4')
+ .expect('12345', done);
+ })
+
+ it('should be inclusive', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=0-0')
+ .expect('1', done);
+ })
+
+ it('should set Content-Range', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=2-5')
+ .expect('Content-Range', 'bytes 2-5/9', done);
+ })
+
+ it('should support -n', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=-3')
+ .expect('789', done);
+ })
+
+ it('should support n-', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=3-')
+ .expect('456789', done);
+ })
+
+ it('should respond with 206 "Partial Content"', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=0-4')
+ .expect(206, done);
+ })
+
+ it('should set Content-Length to the # of octets transferred', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=2-3')
+ .expect('34')
+ .expect('Content-Length', '2')
+ .end(done);
+ })
+
+ describe('when last-byte-pos of the range is greater the length', function(){
+ it('is taken to be equal to one less than the length', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=2-50')
+ .expect('Content-Range', 'bytes 2-8/9')
+ .end(done);
+ })
+
+ it('should adapt the Content-Length accordingly', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=2-50')
+ .expect('Content-Length', '7')
+ .end(done);
+ })
+ })
+
+ describe('when the first- byte-pos of the range is greater length', function(){
+ it('should respond with 416', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'bytes=9-50')
+ .expect('Content-Range', 'bytes */9')
+ .expect(416, done);
+ })
+ })
+
+ describe('when syntactically invalid', function(){
+ it('should respond with 200 and the entire contents', function(done){
+ request(app)
+ .get('/nums')
+ .set('Range', 'asdf')
+ .expect('123456789', done);
+ })
+ })
+ })
+})
+
+describe('send(file, options)', function(){
+ describe('maxAge', function(){
+ it('should default to 0', function(done){
+ request(app)
+ .get('/name.txt')
+ .expect('Cache-Control', 'public, max-age=0')
+ .end(done);
+ })
+
+ it('should support Infinity', function(done){
+ var app = http.createServer(function(req, res){
+ send('test/fixtures/name.txt')
+ .maxage(Infinity)
+ .pipe(res);
+ });
+
+ request(app)
+ .get('/name.txt')
+ .expect('Cache-Control', 'public, max-age=31536000')
+ .end(done);
+ })
+ })
+
+ describe('index', function(){
+ it('should default to index.html', function(done){
+ request(app)
+ .get('/pets/')
+ .expect('tobi\nloki\njane')
+ .end(done);
+ })
+ })
+
+ describe('hidden', function(){
+ it('should default to false', function(done){
+ request(app)
+ .get('/.secret')
+ .expect(404)
+ .expect('not found')
+ .end(done);
+ })
+ })
+
+ describe('root', function(){
+ describe('when given', function(){
+ it('should join root', function(done){
+ var app = http.createServer(function(req, res){
+ send(req.url)
+ .root(__dirname + '/fixtures')
+ .pipe(res);
+ });
+
+ request(app)
+ .get('/pets/../name.txt')
+ .expect('tobi')
+ .end(done);
+ })
+
+ it('should restrict paths to within root', function(done){
+ var app = http.createServer(function(req, res){
+ send(req.url)
+ .root(__dirname + '/fixtures')
+ .on('error', function(err){ res.end(err.message) })
+ .pipe(res);
+ });
+
+ request(app)
+ .get('/pets/../../send.js')
+ .expect('forbidden')
+ .end(done);
+ })
+ })
+
+ describe('when missing', function(){
+ it('should consider .. malicious', function(done){
+ request(app)
+ .get('/../send.js')
+ .expect(403)
+ .expect('forbidden')
+ .end(done);
+ })
+ })
+ })
+})
Please sign in to comment.
Something went wrong with that request. Please try again.