From 722c64f4643fce3217e41837594387c15e7055a5 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 16:26:27 -0700 Subject: [PATCH 01/14] Fix errors with index handling; permit multiple indexes. --- index.js | 166 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 91 insertions(+), 75 deletions(-) diff --git a/index.js b/index.js index 8d87a04..783b065 100644 --- a/index.js +++ b/index.js @@ -61,85 +61,109 @@ async function send (ctx, path, opts = {}) { path = decode(path) if (path === -1) return ctx.throw(400, 'failed to decode') - - // index file support - if (index && trailingSlash) path += index - path = resolvePath(root, path) - // hidden file support, ignore - if (!hidden && isHidden(root, path)) return - - let encodingExt = '' - // serve brotli file when possible otherwise gzipped file when possible - if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await fs.exists(path + '.br'))) { - path = path + '.br' - ctx.set('Content-Encoding', 'br') - ctx.res.removeHeader('Content-Length') - encodingExt = '.br' - } else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await fs.exists(path + '.gz'))) { - path = path + '.gz' - ctx.set('Content-Encoding', 'gzip') - ctx.res.removeHeader('Content-Length') - encodingExt = '.gz' - } - - if (extensions && !/\.[^/]*$/.exec(path)) { - const list = [].concat(extensions) - for (let i = 0; i < list.length; i++) { - let ext = list[i] - if (typeof ext !== 'string') { - throw new TypeError('option extensions must be array of strings or false') - } - if (!/^\./.exec(ext)) ext = '.' + ext - if (await fs.exists(path + ext)) { - path = path + ext - break + /** + * Create a list of all possible matches for the given path and options + * The list will be in order of preferred match, namely, for each possible index: + * A brotli version of the index + * A gzip version of the index + * The index + * A brotli version of the index with one of its extensions + * A gip version of the index with one of its extensions + * The index with one of its extension + */ + let paths = [].concat(trailingSlash ? index : [].concat('', index)) // All of the possible index files + .map(i=>path + (i ? '/' : '') + i) // The permutations of the path with all of the possible indexes + .reduce((p,c)=>{ // each c is a possible match. Collect the compressed and extended versions and the compressed versions of the extended versions + let eP = extendedPath(c) + return p.concat( + compressedPath(ctx, c, brotli, gzip) + ,{path:c, ext: extname(c)} + ,eP.reduce((o,n)=>o.concat(compressedPath(ctx, n, brotli, gzip), n), []) + ) + }, []) + + for (path of paths) { + // hidden file support, ignore + if (!hidden && isHidden(root, path.path)) continue + + // stat + let stats + try { + stats = await fs.stat(path) + if (stats.isDirectory()) continue + } catch (err) { + const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'] + if (notfound.includes(err.code)) { + continue } + err.status = 500 + throw err } - } - // stat - let stats - try { - stats = await fs.stat(path) - - // Format the path to serve static file servers - // and not require a trailing slash for directories, - // so that you can do both `/directory` and `/directory/` - if (stats.isDirectory()) { - if (format && index) { - path += '/' + index - stats = await fs.stat(path) - } else { - return + /** + * The current path permutation exists and is not a directory + * We will serve this and be done + */ + if (setHeaders) setHeaders(ctx.res, path, stats) + + // stream + ctx.set('Content-Length', stats.size) + if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()) + if (!ctx.response.get('Cache-Control')) { + const directives = ['max-age=' + (maxage / 1000 | 0)] + if (immutable) { + directives.push('immutable') } + ctx.set('Cache-Control', directives.join(',')) } - } catch (err) { - const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'] - if (notfound.includes(err.code)) { - throw createError(404, err) - } - err.status = 500 - throw err + ctx.type = path.ext + if (path.fixup) path.fixup() + ctx.body = fs.createReadStream(path.path) + return path.path } + throw createError(404) +} - if (setHeaders) setHeaders(ctx.res, path, stats) +/** + * Return permutations of the path appended with compression option extensions + */ - // stream - ctx.set('Content-Length', stats.size) - if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()) - if (!ctx.response.get('Cache-Control')) { - const directives = ['max-age=' + (maxage / 1000 | 0)] - if (immutable) { - directives.push('immutable') - } - ctx.set('Cache-Control', directives.join(',')) +function compressedPath(ctx, path, brotli, gzip){ + let paths = [] + // serve brotli file when possible otherwise gzipped file when possible + if (brotli && 'br' === ctx.acceptsEncodings('br', 'identity') && ! /\.br$/.test(path)){ + paths.push({path: path + '.br', ext: extname(path), fixup: function(){ + ctx.set('Content-Encoding', 'br') + ctx.res.removeHeader('Content-Length') + }}) + } + if (gzip && 'gzip' === ctx.acceptsEncodings('gzip', 'identity') && ! /\.gz$/.test(path)){ + paths.push({path: path + '.gz', ext: extname(path), fixup: function(){ + ctx.set('Content-Encoding', 'gzip') + ctx.res.removeHeader('Content-Length') + }}) } - ctx.type = type(path, encodingExt) - ctx.body = fs.createReadStream(path) + return paths +} + +/** + * Return permutations of the path appended with option extensions + */ - return path +function extendedPath(path, extensions){ + let paths = [] + if (extensions && !/\.[^/]*$/.test(path)) { + const list = [].concat(extensions) + for (ext of list){ + if ('string' !== typeof ext) { + throw new TypeError('option extensions must be array of strings or false') + } + ext.replace(/^\./, '') + paths.push({path: [path,ext].join('.'), ext}) + } + } } /** @@ -154,14 +178,6 @@ function isHidden (root, path) { return false } -/** - * File type. - */ - -function type (file, ext) { - return ext !== '' ? extname(basename(file, ext)) : extname(file) -} - /** * Decode `path`. */ From 13daa3edd42eac06c38a4bf0da87bad91d5fb4aa Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 16:53:23 -0700 Subject: [PATCH 02/14] Fixed the return value of function extendedPath() --- index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/index.js b/index.js index 783b065..956869a 100644 --- a/index.js +++ b/index.js @@ -164,6 +164,7 @@ function extendedPath(path, extensions){ paths.push({path: [path,ext].join('.'), ext}) } } + return paths } /** From d64ba007fd9a4be695bc8a0db84a4c3f12cb9397 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 16:59:04 -0700 Subject: [PATCH 03/14] Put the '.' back on extension types. --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 956869a..7ea8bab 100644 --- a/index.js +++ b/index.js @@ -161,7 +161,7 @@ function extendedPath(path, extensions){ throw new TypeError('option extensions must be array of strings or false') } ext.replace(/^\./, '') - paths.push({path: [path,ext].join('.'), ext}) + paths.push({path: [path,ext].join('.'), ext:'.'+ext}) } } return paths From 10228e50e8975e9147805c2c3ac2fe01ef531b49 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 17:07:04 -0700 Subject: [PATCH 04/14] Replaced path with path.path where I failed to do so earlier. --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 7ea8bab..c1219ef 100644 --- a/index.js +++ b/index.js @@ -91,7 +91,7 @@ async function send (ctx, path, opts = {}) { // stat let stats try { - stats = await fs.stat(path) + stats = await fs.stat(path.path) if (stats.isDirectory()) continue } catch (err) { const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'] @@ -106,7 +106,7 @@ async function send (ctx, path, opts = {}) { * The current path permutation exists and is not a directory * We will serve this and be done */ - if (setHeaders) setHeaders(ctx.res, path, stats) + if (setHeaders) setHeaders(ctx.res, path.path, stats) // stream ctx.set('Content-Length', stats.size) From 409d054355c65a0b8afbe5d93167ec696e218205 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 17:26:11 -0700 Subject: [PATCH 05/14] Factor option.format back in --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index c1219ef..09228ac 100644 --- a/index.js +++ b/index.js @@ -73,7 +73,7 @@ async function send (ctx, path, opts = {}) { * A gip version of the index with one of its extensions * The index with one of its extension */ - let paths = [].concat(trailingSlash ? index : [].concat('', index)) // All of the possible index files + let paths = [].concat(trailingSlash ? index : [''].concat(format ? index : [])) // All of the possible index files .map(i=>path + (i ? '/' : '') + i) // The permutations of the path with all of the possible indexes .reduce((p,c)=>{ // each c is a possible match. Collect the compressed and extended versions and the compressed versions of the extended versions let eP = extendedPath(c) From 80b497454da7b0115c98e59cbe36b957d79477c3 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 18:23:58 -0700 Subject: [PATCH 06/14] Should be accurate to report Content-Length from stats --- index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/index.js b/index.js index 09228ac..00294cc 100644 --- a/index.js +++ b/index.js @@ -136,13 +136,11 @@ function compressedPath(ctx, path, brotli, gzip){ if (brotli && 'br' === ctx.acceptsEncodings('br', 'identity') && ! /\.br$/.test(path)){ paths.push({path: path + '.br', ext: extname(path), fixup: function(){ ctx.set('Content-Encoding', 'br') - ctx.res.removeHeader('Content-Length') }}) } if (gzip && 'gzip' === ctx.acceptsEncodings('gzip', 'identity') && ! /\.gz$/.test(path)){ paths.push({path: path + '.gz', ext: extname(path), fixup: function(){ ctx.set('Content-Encoding', 'gzip') - ctx.res.removeHeader('Content-Length') }}) } return paths From f98da2b7c0a39ba24fb7c6bb6db00466d4e90388 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 18:38:44 -0700 Subject: [PATCH 07/14] Removed guard for appending extensions --- index.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 00294cc..37c96a9 100644 --- a/index.js +++ b/index.js @@ -152,15 +152,12 @@ function compressedPath(ctx, path, brotli, gzip){ function extendedPath(path, extensions){ let paths = [] - if (extensions && !/\.[^/]*$/.test(path)) { - const list = [].concat(extensions) - for (ext of list){ - if ('string' !== typeof ext) { - throw new TypeError('option extensions must be array of strings or false') - } - ext.replace(/^\./, '') - paths.push({path: [path,ext].join('.'), ext:'.'+ext}) + for (ext of [].concat(extensions||[])){ + if ('string' !== typeof ext) { + throw new TypeError('option extensions must be array of strings or false') } + ext.replace(/^\./, '') + paths.push({path: [path,ext].join('.'), ext:'.'+ext}) } return paths } From 1385b662ed1df0b2ada32439dbad99b1811f9569 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 18:45:18 -0700 Subject: [PATCH 08/14] Fixed extensions --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 37c96a9..37d574e 100644 --- a/index.js +++ b/index.js @@ -76,7 +76,7 @@ async function send (ctx, path, opts = {}) { let paths = [].concat(trailingSlash ? index : [''].concat(format ? index : [])) // All of the possible index files .map(i=>path + (i ? '/' : '') + i) // The permutations of the path with all of the possible indexes .reduce((p,c)=>{ // each c is a possible match. Collect the compressed and extended versions and the compressed versions of the extended versions - let eP = extendedPath(c) + let eP = extensions ? extendedPath(c, extensions) : [] return p.concat( compressedPath(ctx, c, brotli, gzip) ,{path:c, ext: extname(c)} From e6ab3d58eb6317ce19236dd866aa7f5e429203f2 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 18:55:09 -0700 Subject: [PATCH 09/14] Better handling of 404 --- index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 37d574e..2640b3c 100644 --- a/index.js +++ b/index.js @@ -96,6 +96,8 @@ async function send (ctx, path, opts = {}) { } catch (err) { const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'] if (notfound.includes(err.code)) { + if (Object.is(path, paths[paths.length-1])) + throw createError(404, err) continue } err.status = 500 @@ -123,7 +125,6 @@ async function send (ctx, path, opts = {}) { ctx.body = fs.createReadStream(path.path) return path.path } - throw createError(404) } /** From 5db1e2fec4f6e910de942ec697a0f1b86e3b75d6 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 19:12:28 -0700 Subject: [PATCH 10/14] Just return on hidden files and directories --- index.js | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 2640b3c..f2964c8 100644 --- a/index.js +++ b/index.js @@ -84,31 +84,42 @@ async function send (ctx, path, opts = {}) { ) }, []) - for (path of paths) { + for (let candidate of paths) { // hidden file support, ignore - if (!hidden && isHidden(root, path.path)) continue + if (!hidden && isHidden(root, candidate.path)){ + if (Object.is(candidate, paths[paths.length-1])) + return + else + continue + } // stat let stats try { - stats = await fs.stat(path.path) - if (stats.isDirectory()) continue + stats = await fs.stat(candidate.path) + if (stats.isDirectory()){ + if (Object.is(candidate, paths[paths.length-1])) + return + else + continue + } } catch (err) { const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'] if (notfound.includes(err.code)) { - if (Object.is(path, paths[paths.length-1])) + if (Object.is(candidate, paths[paths.length-1])) throw createError(404, err) - continue + else + continue } err.status = 500 throw err } /** - * The current path permutation exists and is not a directory + * The current candidate permutation exists and is not a directory * We will serve this and be done */ - if (setHeaders) setHeaders(ctx.res, path.path, stats) + if (setHeaders) setHeaders(ctx.res, candidate.path, stats) // stream ctx.set('Content-Length', stats.size) @@ -120,10 +131,10 @@ async function send (ctx, path, opts = {}) { } ctx.set('Cache-Control', directives.join(',')) } - ctx.type = path.ext - if (path.fixup) path.fixup() - ctx.body = fs.createReadStream(path.path) - return path.path + ctx.type = candidate.ext + if (candidate.fixup) candidate.fixup() + ctx.body = fs.createReadStream(candidate.path) + return candidate.path } } From 6b201858498adf6076f33afbf284636b2e8e1549 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 20:05:38 -0700 Subject: [PATCH 11/14] fixed error passing extended path to compressedPath() --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index f2964c8..f863fb0 100644 --- a/index.js +++ b/index.js @@ -80,7 +80,7 @@ async function send (ctx, path, opts = {}) { return p.concat( compressedPath(ctx, c, brotli, gzip) ,{path:c, ext: extname(c)} - ,eP.reduce((o,n)=>o.concat(compressedPath(ctx, n, brotli, gzip), n), []) + ,eP.reduce((o,n)=>o.concat(compressedPath(ctx, n.path, brotli, gzip), n), []) ) }, []) @@ -164,7 +164,7 @@ function compressedPath(ctx, path, brotli, gzip){ function extendedPath(path, extensions){ let paths = [] - for (ext of [].concat(extensions||[])){ + for (ext of extensions){ if ('string' !== typeof ext) { throw new TypeError('option extensions must be array of strings or false') } From bfd7005168e6eda9d8b186c45cd428e94c9de696 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 20:11:18 -0700 Subject: [PATCH 12/14] Fixed dot replacement on extension --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index f863fb0..9c9a3f6 100644 --- a/index.js +++ b/index.js @@ -168,7 +168,7 @@ function extendedPath(path, extensions){ if ('string' !== typeof ext) { throw new TypeError('option extensions must be array of strings or false') } - ext.replace(/^\./, '') + ext = ext.replace(/^\./, '') paths.push({path: [path,ext].join('.'), ext:'.'+ext}) } return paths From 46c4c93e472ae6e4e5484fbe0291b0d58ee422a5 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 20:52:33 -0700 Subject: [PATCH 13/14] Added tests which should highlight errors in compression and index handling --- test/fixtures/br.json.gz | Bin 0 -> 48 bytes test/fixtures/index.html/index | 1 + test/fixtures/world/world | 1 + test/index.js | 42 +++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 test/fixtures/br.json.gz create mode 100644 test/fixtures/index.html/index create mode 100644 test/fixtures/world/world diff --git a/test/fixtures/br.json.gz b/test/fixtures/br.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..89b085de38a13bddfe1ee1066a26c5a2aa1d1e24 GIT binary patch literal 48 zcmb2|=HN)qxDdj?oL-e#pqEvgpU1E|EZ~&)8Q;)NK>=DP{ZEAitzczXU(NMRh=G9t E0Hsk85&!@I literal 0 HcmV?d00001 diff --git a/test/fixtures/index.html/index b/test/fixtures/index.html/index new file mode 100644 index 0000000..2fa59a7 --- /dev/null +++ b/test/fixtures/index.html/index @@ -0,0 +1 @@ +text index \ No newline at end of file diff --git a/test/fixtures/world/world b/test/fixtures/world/world new file mode 100644 index 0000000..3e575fa --- /dev/null +++ b/test/fixtures/world/world @@ -0,0 +1 @@ +html index \ No newline at end of file diff --git a/test/index.js b/test/index.js index f1f9c6a..29664fa 100644 --- a/test/index.js +++ b/test/index.js @@ -180,6 +180,33 @@ describe('send(ctx, file)', function () { .expect('html index', done) }) }) + + describe('when the index file is not present', function () { + it('should 404 if the index is a directory', function (done) { + const app = new Koa() + + app.use(async (ctx) => { + const opts = { root: 'test', index: 'index', extensions:['html', 'htm'] } + await send(ctx, 'fixtures/', opts) + }) + + request(app.listen()) + .get('/') + .expect(404, done) + }) + + it('should 404 if the index is a directory', function (done) { + const app = new Koa() + + app.use(async (ctx) => { + const opts = { root: 'test', index: 'world' } + await send(ctx, 'fixtures/', opts) + }) + + request(app.listen()) + .get('/') + .expect(404, done) + }) }) describe('when path is not a file', function () { @@ -467,6 +494,21 @@ describe('send(ctx, file)', function () { .expect('{ "name": "tobi" }') .expect(200, done) }) + + it('should return .gz path when brotli is unavailable', function (done) { + const app = new Koa() + + app.use(async (ctx) => { + await send(ctx, '/test/fixtures/br.json') + }) + + request(app.listen()) + .get('/') + .set('Accept-Encoding', 'br, gzip, deflate, identity') + .expect('Content-Length', '48') + .expect('{ "name": "tobi" }') + .expect(200, done) + }) }) describe('and max age is specified', function () { From 78e760e1744e52ef71959bc0d1c14b8fd939a769 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 22 Mar 2018 21:02:22 -0700 Subject: [PATCH 14/14] Restored lost braces --- test/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/index.js b/test/index.js index 29664fa..d72a503 100644 --- a/test/index.js +++ b/test/index.js @@ -207,6 +207,7 @@ describe('send(ctx, file)', function () { .get('/') .expect(404, done) }) + }) }) describe('when path is not a file', function () {