Skip to content
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
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ app.use(serve(root, opts));
- `defer` If true, serves after `return next()`, allowing any downstream middleware to respond first.
- `gzip` Try to serve the gzipped version of a file automatically when gzip is supported by a client and if the requested file with .gz extension exists. defaults to true.
- `brotli` Try to serve the brotli version of a file automatically when brotli is supported by a client and if the requested file with .br extension exists (note, that brotli is only accepted over https). defaults to true.
- `format` Directory handling mode. By default, directory requests without a trailing slash redirect to the slash-appended URL when an index file exists. If `true`, directory indexes are served without requiring a trailing slash. If `false`, directory index formatting is disabled.
- [setHeaders](https://github.com/koajs/send#setheaders) Function to set custom headers on response.
- `extensions` Try to match extensions from passed array to search for file when no extension is sufficed in URL. First found is served. (defaults to `false`). e.g. `['html']`

Expand Down
81 changes: 78 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@

'use strict'

/**
* Module dependencies.
*/

const debug = require('debug')('koa-static')
const fs = require('fs')
const path = require('path')
const assert = require('assert')
const send = require('koa-send')
Expand Down Expand Up @@ -38,7 +38,7 @@ function serve (root, opts = {}) {

if (ctx.method === 'HEAD' || ctx.method === 'GET') {
try {
done = await send(ctx, ctx.path, opts)
done = await redirectToDirectorySlash(ctx, opts) || await send(ctx, ctx.path, opts)
} catch (err) {
if (err.status !== 404) {
throw err
Expand All @@ -60,11 +60,86 @@ function serve (root, opts = {}) {
if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line

try {
await send(ctx, ctx.path, opts)
await redirectToDirectorySlash(ctx, opts) || await send(ctx, ctx.path, opts)
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}
}

async function redirectToDirectorySlash (ctx, opts) {
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return false
if (!opts.index || ctx.path[ctx.path.length - 1] === '/') return false
if (opts.format !== undefined && opts.format !== 'redirect') return false

const pathname = decode(ctx.path)
if (pathname === -1 || pathname.indexOf('\0') !== -1) return false

const dir = resolveFromRoot(opts.root, pathname)
if (!dir || (!opts.hidden && isHidden(opts.root, dir))) return false

let stats
try {
stats = await fs.promises.stat(dir)
} catch (err) {
if (isNotFound(err)) return false
throw err
}

if (!stats.isDirectory()) return false

try {
stats = await fs.promises.stat(path.join(dir, opts.index))
} catch (err) {
if (isNotFound(err)) return false
throw err
}

if (!stats.isFile()) return false

ctx.redirect(ctx.path + '/' + getSearch(ctx))
return true
}

function decode (pathname) {
try {
return decodeURIComponent(pathname)
} catch (err) {
return -1
}
}

function isHidden (root, pathname) {
const parts = path.relative(root, pathname).split(path.sep)

for (let i = 0; i < parts.length; i++) {
if (parts[i][0] === '.') return true
}

return false
}

function isNotFound (err) {
return err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'ENAMETOOLONG'
}

function getSearch (ctx) {
const search = ctx.search

if (!search) return ''
return search[0] === '?' ? search : `?${search}`
}

function resolveFromRoot (root, pathname) {
const filename = pathname.slice(path.parse(pathname).root.length)
const resolved = path.resolve(root, filename)
const relative = path.relative(root, resolved)

if (relative === '' || (relative.substr(0, 2) !== '..' && !path.isAbsolute(relative))) {
return resolved
}

return null
}
1 change: 1 addition & 0 deletions test/fixtures/empty/hello.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
empty fixture
76 changes: 75 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

'use strict'

const request = require('supertest')
Expand Down Expand Up @@ -120,6 +119,38 @@ describe('serve(root)', function () {
.expect('Content-Type', 'text/html; charset=utf-8')
.expect('html index', done)
})

it('should redirect directory requests missing a trailing slash', function (done) {
const app = new Koa()

app.use(serve('test/fixtures'))

request(app.listen())
.get('/world')
.expect(302)
.expect('Location', '/world/', done)
})

it('should preserve the query string when redirecting a directory request', function (done) {
const app = new Koa()

app.use(serve('test/fixtures'))

request(app.listen())
.get('/world?foo=bar')
.expect(302)
.expect('Location', '/world/?foo=bar', done)
})

it('should not redirect when the directory has no index file', function (done) {
const app = new Koa()

app.use(serve('test/fixtures'))

request(app.listen())
.get('/empty')
.expect(404, done)
})
})

describe('when disabled', function () {
Expand Down Expand Up @@ -230,6 +261,34 @@ describe('serve(root)', function () {
.expect('Content-Type', 'text/html; charset=utf-8')
.expect('html index', done)
})

it('should redirect directory requests missing a trailing slash', function (done) {
const app = new Koa()

app.use(serve('test/fixtures', {
defer: true
}))

request(app.listen())
.get('/world?foo=bar')
.expect(302)
.expect('Location', '/world/?foo=bar', done)
})

it('should not redirect when the directory has no index file', function (done) {
const app = new Koa()

app.use(serve('test/fixtures', {
defer: true
}))

request(app.listen())
.get('/empty?foo=bar')
.expect((res) => {
assert.equal(res.headers.location, undefined)
})
.expect(404, done)
})
})
})

Expand Down Expand Up @@ -346,6 +405,21 @@ describe('serve(root)', function () {
.post('/hello.txt')
.expect(404, done)
})

it('should not redirect directory requests', function (done) {
const app = new Koa()

app.use(serve('test/fixtures', {
defer: true
}))

request(app.listen())
.post('/world')
.expect((res) => {
assert.equal(res.headers.location, undefined)
})
.expect(404, done)
})
})
})

Expand Down