diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5395d92..f916409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: jobs: test: - uses: fastify/workflows/.github/workflows/plugins-ci.yml@v4.1.0 + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5.0.0 with: license-check: true lint: true diff --git a/README.md b/README.md index 2b2ee40..c52fa9b 100644 --- a/README.md +++ b/README.md @@ -477,13 +477,13 @@ If an error occurs while trying to send a file, the error will be passed to Fastify's error handler. You can set a custom error handler with [`fastify.setErrorHandler()`](https://fastify.dev/docs/latest/Reference/Server/#seterrorhandler). -### Payload `stream.filename` +### Payload `stream.path` -If you need to access the filename inside the `onSend` hook, you can use `payload.filename`. +If you need to access the file path inside the `onSend` hook, you can use `payload.path`. ```js fastify.addHook('onSend', function (req, reply, payload, next) { - console.log(payload.filename) + console.log(payload.path) next() }) ``` diff --git a/index.js b/index.js index 8e3d4c2..ccb81af 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,5 @@ 'use strict' -const { PassThrough } = require('node:stream') const path = require('node:path') const { fileURLToPath } = require('node:url') const { statSync } = require('node:fs') @@ -170,7 +169,7 @@ async function fastifyStatic (fastify, opts) { const allowedPath = opts.allowedPath - function pumpSendToReply ( + async function pumpSendToReply ( request, reply, pathname, @@ -222,163 +221,148 @@ async function fastifyStatic (fastify, opts) { } // `send(..., path, ...)` will URI-decode path so we pass an encoded path here - const stream = send(request.raw, encodeURI(pathnameForSend), options) - let resolvedFilename - stream.on('file', function (file) { - resolvedFilename = file - }) - - const wrap = new PassThrough({ - flush (cb) { - this.finished = true - if (reply.raw.statusCode === 304) { - reply.send('') + const { + statusCode, + headers, + stream, + type, + metadata + } = await send(request.raw, encodeURI(pathnameForSend), options) + switch (type) { + case 'directory': { + const path = metadata.path + if (opts.list) { + await dirList.send({ + reply, + dir: path, + options: opts.list, + route: pathname, + prefix, + dotfiles: opts.dotfiles + }).catch((err) => reply.send(err)) } - cb() - } - }) - wrap.getHeader = reply.getHeader.bind(reply) - wrap.setHeader = reply.header.bind(reply) - wrap.removeHeader = () => {} - wrap.finished = false + if (opts.redirect === true) { + try { + reply.redirect(301, getRedirectUrl(request.raw.url)) + } /* c8 ignore start */ catch (error) { + // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack + await reply.send(error) + } /* c8 ignore stop */ + } else { + // if is a directory path without a trailing slash, and has an index file, reply as if it has a trailing slash + if (!pathname.endsWith('/') && findIndexFile(pathname, options.root, options.index)) { + return pumpSendToReply( + request, + reply, + pathname + '/', + rootPath, + undefined, + undefined, + checkedEncodings + ) + } - Object.defineProperty(wrap, 'filename', { - get () { - return resolvedFilename - } - }) - Object.defineProperty(wrap, 'statusCode', { - get () { - return reply.raw.statusCode - }, - set (code) { - reply.code(code) + reply.callNotFound() + } + break } - }) - - if (request.method === 'HEAD') { - wrap.on('finish', reply.send.bind(reply)) - } else { - wrap.on('pipe', function () { - if (encoding) { - reply.header('content-type', getContentType(pathname)) - reply.header('content-encoding', encoding) + case 'error': { + if ( + statusCode === 403 && + (!options.index || !options.index.length) && + pathnameForSend[pathnameForSend.length - 1] === '/' + ) { + if (opts.list) { + await dirList.send({ + reply, + dir: dirList.path(opts.root, pathname), + options: opts.list, + route: pathname, + prefix, + dotfiles: opts.dotfiles + }).catch((err) => reply.send(err)) + } } - reply.send(wrap) - }) - } - - if (setHeaders !== undefined) { - stream.on('headers', setHeaders) - } - stream.on('directory', function (_, path) { - if (opts.list) { - dirList.send({ - reply, - dir: path, - options: opts.list, - route: pathname, - prefix, - dotfiles: opts.dotfiles - }).catch((err) => reply.send(err)) - return - } + if (metadata.error.code === 'ENOENT') { + // when preCompress is enabled and the path is a directory without a trailing slash + if (opts.preCompressed && encoding) { + const indexPathname = findIndexFile(pathname, options.root, options.index) + if (indexPathname) { + return pumpSendToReply( + request, + reply, + pathname + '/', + rootPath, + undefined, + undefined, + checkedEncodings + ) + } + } - if (opts.redirect === true) { - try { - reply.redirect(301, getRedirectUrl(request.raw.url)) - } /* c8 ignore start */ catch (error) { - // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack - reply.send(error) - } /* c8 ignore stop */ - } else { - // if is a directory path without a trailing slash, and has an index file, reply as if it has a trailing slash - if (!pathname.endsWith('/') && findIndexFile(pathname, options.root, options.index)) { - return pumpSendToReply( - request, - reply, - pathname + '/', - rootPath, - undefined, - undefined, - checkedEncodings - ) - } + // if file exists, send real file, otherwise send dir list if name match + if (opts.list && dirList.handle(pathname, opts.list)) { + await dirList.send({ + reply, + dir: dirList.path(opts.root, pathname), + options: opts.list, + route: pathname, + prefix, + dotfiles: opts.dotfiles + }).catch((err) => reply.send(err)) + return + } - reply.callNotFound() - } - }) + // root paths left to try? + if (Array.isArray(rootPath) && rootPathOffset < (rootPath.length - 1)) { + return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1) + } - stream.on('error', function (err) { - if (err.code === 'ENOENT') { - // when preCompress is enabled and the path is a directory without a trailing slash - if (opts.preCompressed && encoding) { - const indexPathname = findIndexFile(pathname, options.root, options.index) - if (indexPathname) { + if (opts.preCompressed && !checkedEncodings.has(encoding)) { + checkedEncodings.add(encoding) return pumpSendToReply( request, reply, - pathname + '/', + pathnameOrig, rootPath, - undefined, + rootPathOffset, undefined, checkedEncodings ) } - } - - // if file exists, send real file, otherwise send dir list if name match - if (opts.list && dirList.handle(pathname, opts.list)) { - dirList.send({ - reply, - dir: dirList.path(opts.root, pathname), - options: opts.list, - route: pathname, - prefix, - dotfiles: opts.dotfiles - }).catch((err) => reply.send(err)) - return - } - // root paths left to try? - if (Array.isArray(rootPath) && rootPathOffset < (rootPath.length - 1)) { - return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1) + return reply.callNotFound() } - if (opts.preCompressed && !checkedEncodings.has(encoding)) { - checkedEncodings.add(encoding) - return pumpSendToReply( - request, - reply, - pathnameOrig, - rootPath, - rootPathOffset, - undefined, - checkedEncodings - ) + // The `send` library terminates the request with a 404 if the requested + // path contains a dotfile and `send` is initialized with `{dotfiles: + // 'ignore'}`. `send` aborts the request before getting far enough to + // check if the file exists (hence, a 404 `NotFoundError` instead of + // `ENOENT`). + // https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L582 + if (metadata.error.status === 404) { + return reply.callNotFound() } - return reply.callNotFound() + await reply.send(metadata.error) + break } - - // The `send` library terminates the request with a 404 if the requested - // path contains a dotfile and `send` is initialized with `{dotfiles: - // 'ignore'}`. `send` aborts the request before getting far enough to - // check if the file exists (hence, a 404 `NotFoundError` instead of - // `ENOENT`). - // https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L582 - if (err.status === 404) { - return reply.callNotFound() + case 'file': { + reply.code(statusCode) + if (setHeaders !== undefined) { + setHeaders(reply.raw, metadata.path, metadata.stat) + } + reply.headers(headers) + if (encoding) { + reply.header('content-type', getContentType(pathname)) + reply.header('content-encoding', encoding) + } + await reply.send(stream) + break } - - reply.send(err) - }) - - // we cannot use pump, because send error - // handling is not compatible - stream.pipe(wrap) + } } function setUpHeadAndGet (routeOpts, route, file, rootPath) { @@ -393,11 +377,11 @@ async function fastifyStatic (fastify, opts) { fastify.route(toSetUp) } - function serveFileHandler (req, reply) { + async function serveFileHandler (req, reply) { // TODO: remove the fallback branch when bump major /* c8 ignore next */ const routeConfig = req.routeOptions?.config || req.routeConfig - pumpSendToReply(req, reply, routeConfig.file, routeConfig.rootPath) + return pumpSendToReply(req, reply, routeConfig.file, routeConfig.rootPath) } } @@ -547,7 +531,7 @@ function getRedirectUrl (url) { } module.exports = fp(fastifyStatic, { - fastify: '4.x', + // fastify: '4.x', name: '@fastify/static' }) module.exports.default = fastifyStatic diff --git a/lib/dirList.js b/lib/dirList.js index 7a9cd3f..f55319b 100644 --- a/lib/dirList.js +++ b/lib/dirList.js @@ -125,9 +125,9 @@ const dirList = { entries.dirs.forEach(entry => nameEntries.dirs.push(entry.name)) entries.files.forEach(entry => nameEntries.files.push(entry.name)) - reply.send(nameEntries) + await reply.send(nameEntries) } else { - reply.send(entries) + await reply.send(entries) } return } @@ -135,7 +135,7 @@ const dirList = { const html = options.render( entries.dirs.map(entry => dirList.htmlInfo(entry, route, prefix, options)), entries.files.map(entry => dirList.htmlInfo(entry, route, prefix, options))) - reply.type('text/html').send(html) + await reply.type('text/html').send(html) }, /** diff --git a/package.json b/package.json index 491929d..51de680 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "homepage": "https://github.com/fastify/fastify-static", "dependencies": { "@fastify/accept-negotiator": "^2.0.0-pre.fv5.1", - "@fastify/send": "^3.0.0-pre.fv5.1", + "@fastify/send": "^3.1.0", "content-disposition": "^0.5.4", "fastify-plugin": "^5.0.0-pre.fv5.1", "fastq": "^1.17.1", diff --git a/test/dir-list.test.js b/test/dir-list.test.js index 7d42202..1a6449f 100644 --- a/test/dir-list.test.js +++ b/test/dir-list.test.js @@ -171,6 +171,34 @@ t.test('dir list, custom options', t => { }) }) +t.test('dir list, custom options with empty array index', t => { + t.plan(2) + + const options = { + root: path.join(__dirname, '/static'), + prefix: '/public', + index: [], + list: true + } + + const route = '/public/' + const content = { dirs: ['deep', 'shallow'], files: ['.example', '100%.txt', 'a .md', 'foo.html', 'foobar.html', 'index.css', 'index.html'] } + + helper.arrange(t, options, (url) => { + t.test(route, t => { + t.plan(3) + simple.concat({ + method: 'GET', + url: url + route + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(body.toString(), JSON.stringify(content)) + }) + }) + }) +}) + t.test('dir list html format', t => { t.plan(6) @@ -471,7 +499,7 @@ t.test('dir list json format - extended info', t => { t.equal(response.statusCode, 200) const bodyObject = JSON.parse(body.toString()) t.equal(bodyObject.dirs[0].name, 'empty') - t.equal(typeof bodyObject.dirs[0].stats.atime, 'string') + t.equal(typeof bodyObject.dirs[0].stats.atimeMs, 'number') t.equal(typeof bodyObject.dirs[0].extendedInfo.totalSize, 'number') }) }) @@ -845,7 +873,7 @@ t.test('dir list error', t => { const errorMessage = 'mocking send' dirList.send = async () => { throw new Error(errorMessage) } - const mock = t.mock('..', { + const mock = t.mockRequire('..', { '../lib/dirList.js': dirList }) diff --git a/test/static.test.js b/test/static.test.js index 3a3bb76..608d57a 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -681,7 +681,7 @@ t.test('register /static with constraints', (t) => { }) }) -t.test('payload.filename is set', (t) => { +t.test('payload.path is set', (t) => { t.plan(3) const pluginOptions = { @@ -692,7 +692,7 @@ t.test('payload.filename is set', (t) => { let gotFilename fastify.register(fastifyStatic, pluginOptions) fastify.addHook('onSend', function (req, reply, payload, next) { - gotFilename = payload.filename + gotFilename = payload.path next() }) @@ -1361,13 +1361,13 @@ t.test('root not found warning', (t) => { const destination = concat((data) => { t.equal(JSON.parse(data).msg, `"root" path "${rootPath}" must exist`) }) - const logger = pino( + const loggerInstance = pino( { level: 'warn' }, destination ) - const fastify = Fastify({ logger }) + const fastify = Fastify({ loggerInstance }) fastify.register(fastifyStatic, pluginOptions) fastify.listen({ port: 0 }, (err) => { t.error(err)