diff --git a/Readme.md b/Readme.md index ae90604..df4b7b2 100644 --- a/Readme.md +++ b/Readme.md @@ -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']` diff --git a/index.js b/index.js index 8227b45..e5f2477 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,3 @@ - 'use strict' /** @@ -6,6 +5,7 @@ */ const debug = require('debug')('koa-static') +const fs = require('fs') const path = require('path') const assert = require('assert') const send = require('koa-send') @@ -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 @@ -60,7 +60,7 @@ 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 @@ -68,3 +68,78 @@ function serve (root, opts = {}) { } } } + +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 +} diff --git a/test/fixtures/empty/hello.txt b/test/fixtures/empty/hello.txt new file mode 100644 index 0000000..46de785 --- /dev/null +++ b/test/fixtures/empty/hello.txt @@ -0,0 +1 @@ +empty fixture diff --git a/test/index.js b/test/index.js index e2ea964..4671252 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,3 @@ - 'use strict' const request = require('supertest') @@ -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 () { @@ -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) + }) }) }) @@ -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) + }) }) })