Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix errors with index handling; permit multiple indexes. #98

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 97 additions & 73 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,85 +61,117 @@ 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'
}
/**
* 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(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 = extensions ? extendedPath(c, extensions) : []
return p.concat(
compressedPath(ctx, c, brotli, gzip)
,{path:c, ext: extname(c)}
,eP.reduce((o,n)=>o.concat(compressedPath(ctx, n.path, brotli, gzip), n), [])
)
}, [])

for (let candidate of paths) {
// hidden file support, ignore
if (!hidden && isHidden(root, candidate.path)){
if (Object.is(candidate, paths[paths.length-1]))
return
else
continue
}

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')
// stat
let stats
try {
stats = await fs.stat(candidate.path)
if (stats.isDirectory()){
if (Object.is(candidate, paths[paths.length-1]))
return
else
continue
}
if (!/^\./.exec(ext)) ext = '.' + ext
if (await fs.exists(path + ext)) {
path = path + ext
break
} catch (err) {
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
if (notfound.includes(err.code)) {
if (Object.is(candidate, paths[paths.length-1]))
throw createError(404, err)
else
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 candidate permutation exists and is not a directory
* We will serve this and be done
*/
if (setHeaders) setHeaders(ctx.res, candidate.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 = candidate.ext
if (candidate.fixup) candidate.fixup()
ctx.body = fs.createReadStream(candidate.path)
return candidate.path
}
}

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.type = type(path, encodingExt)
ctx.body = fs.createReadStream(path)
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')
}})
}
return paths
}

/**
* Return permutations of the path appended with option extensions
*/

return path
function extendedPath(path, extensions){
let paths = []
for (ext of extensions){
if ('string' !== typeof ext) {
throw new TypeError('option extensions must be array of strings or false')
}
ext = ext.replace(/^\./, '')
paths.push({path: [path,ext].join('.'), ext:'.'+ext})
}
return paths
}

/**
Expand All @@ -154,14 +186,6 @@ function isHidden (root, path) {
return false
}

/**
* File type.
*/

function type (file, ext) {
return ext !== '' ? extname(basename(file, ext)) : extname(file)
}

/**
* Decode `path`.
*/
Expand Down
Binary file added test/fixtures/br.json.gz
Binary file not shown.
1 change: 1 addition & 0 deletions test/fixtures/index.html/index
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
text index
1 change: 1 addition & 0 deletions test/fixtures/world/world
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
html index
43 changes: 43 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,34 @@ 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 () {
Expand Down Expand Up @@ -467,6 +495,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 () {
Expand Down