'use strict' const { PassThrough } = require('node:stream') const path = require('node:path') const { fileURLToPath } = require('node:url') const { statSync } = require('node:fs') const { glob } = require('glob') const fp = require('fastify-plugin') const send = require('@fastify/send') const encodingNegotiator = require('@fastify/accept-negotiator') const contentDisposition = require('content-disposition') const dirList = require('./lib/dirList') const endForwardSlashRegex = /\/$/u const asteriskRegex = /\*/gu const supportedEncodings = ['br', 'gzip', 'deflate'] send.mime.default_type = 'application/octet-stream' async function fastifyStatic (fastify, opts) { opts.root = normalizeRoot(opts.root) checkRootPathForErrors(fastify, opts.root) const setHeaders = opts.setHeaders if (setHeaders !== undefined && typeof setHeaders !== 'function') { throw new TypeError('The `setHeaders` option must be a function') } const invalidDirListOpts = dirList.validateOptions(opts) if (invalidDirListOpts) { throw invalidDirListOpts } if (opts.dotfiles === undefined) { opts.dotfiles = 'allow' } const sendOptions = { root: opts.root, acceptRanges: opts.acceptRanges, cacheControl: opts.cacheControl, dotfiles: opts.dotfiles, etag: opts.etag, extensions: opts.extensions, immutable: opts.immutable, index: opts.index, lastModified: opts.lastModified, maxAge: opts.maxAge } let prefix = opts.prefix ?? (opts.prefix = '/') if (!opts.prefixAvoidTrailingSlash) { prefix = prefix[prefix.length - 1] === '/' ? prefix : prefix + '/' } // Set the schema hide property if defined in opts or true by default const routeOpts = { constraints: opts.constraints, schema: { hide: opts.schemaHide !== undefined ? opts.schemaHide : true }, errorHandler (error, request, reply) { if (error?.code === 'ERR_STREAM_PREMATURE_CLOSE') { reply.request.raw.destroy() return } fastify.errorHandler(error, request, reply) } } if (opts.decorateReply !== false) { fastify.decorateReply('sendFile', function (filePath, rootPath, options) { const opts = typeof rootPath === 'object' ? rootPath : options const root = typeof rootPath === 'string' ? rootPath : opts && opts.root pumpSendToReply( this.request, this, filePath, root || sendOptions.root, 0, opts ) return this }) fastify.decorateReply( 'download', function (filePath, fileName, options = {}) { const { root, ...opts } = typeof fileName === 'object' ? fileName : options fileName = typeof fileName === 'string' ? fileName : filePath // Set content disposition header this.header('content-disposition', contentDisposition(fileName)) pumpSendToReply(this.request, this, filePath, root, 0, opts) return this } ) } if (opts.serve !== false) { if (opts.wildcard && typeof opts.wildcard !== 'boolean') { throw new Error('"wildcard" option must be a boolean') } if (opts.wildcard === undefined || opts.wildcard === true) { fastify.route({ ...routeOpts, method: ['HEAD', 'GET'], path: prefix + '*', handler (req, reply) { pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root) } }) if (opts.redirect === true && prefix !== opts.prefix) { fastify.get(opts.prefix, routeOpts, (req, reply) => { reply.redirect(301, getRedirectUrl(req.raw.url)) }) } } else { const indexes = opts.index === undefined ? ['index.html'] : [].concat(opts.index) const indexDirs = new Map() const routes = new Set() const roots = Array.isArray(sendOptions.root) ? sendOptions.root : [sendOptions.root] for (let rootPath of roots) { rootPath = rootPath.split(path.win32.sep).join(path.posix.sep) !rootPath.endsWith('/') && (rootPath += '/') const files = await glob('**/**', { cwd: rootPath, absolute: false, follow: true, nodir: true, dot: opts.serveDotFiles }) for (let file of files) { file = file.split(path.win32.sep).join(path.posix.sep) const route = prefix + file if (routes.has(route)) { continue } routes.add(route) setUpHeadAndGet(routeOpts, route, `/${file}`, rootPath) const key = path.posix.basename(route) if (indexes.includes(key) && !indexDirs.has(key)) { indexDirs.set(path.posix.dirname(route), rootPath) } } } for (const [dirname, rootPath] of indexDirs.entries()) { const pathname = dirname + (dirname.endsWith('/') ? '' : '/') const file = '/' + pathname.replace(prefix, '') setUpHeadAndGet(routeOpts, pathname, file, rootPath) if (opts.redirect === true) { setUpHeadAndGet(routeOpts, pathname.replace(endForwardSlashRegex, ''), file.replace(endForwardSlashRegex, ''), rootPath) } } } } const allowedPath = opts.allowedPath function pumpSendToReply ( request, reply, pathname, rootPath, rootPathOffset = 0, pumpOptions, checkedEncodings ) { const pathnameOrig = pathname const options = Object.assign({}, sendOptions, pumpOptions) if (rootPath) { if (Array.isArray(rootPath)) { options.root = rootPath[rootPathOffset] } else { options.root = rootPath } } if (allowedPath && !allowedPath(pathname, options.root, request)) { return reply.callNotFound() } let encoding let pathnameForSend = pathname if (opts.preCompressed) { /** * We conditionally create this structure to track our attempts * at sending pre-compressed assets */ if (!checkedEncodings) { checkedEncodings = new Set() } encoding = getEncodingHeader(request.headers, checkedEncodings) if (encoding) { if (pathname.endsWith('/')) { pathname = findIndexFile(pathname, options.root, options.index) if (!pathname) { return reply.callNotFound() } pathnameForSend = pathnameForSend + pathname + '.' + getEncodingExtension(encoding) } else { pathnameForSend = pathname + '.' + getEncodingExtension(encoding) } } } // `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('') } cb() } }) wrap.getHeader = reply.getHeader.bind(reply) wrap.setHeader = reply.header.bind(reply) wrap.removeHeader = () => {} wrap.finished = false Object.defineProperty(wrap, 'filename', { get () { return resolvedFilename } }) Object.defineProperty(wrap, 'statusCode', { get () { return reply.raw.statusCode }, set (code) { reply.code(code) } }) 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) } 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 (opts.redirect === true) { try { reply.redirect(301, getRedirectUrl(request.raw.url)) } catch (error) { // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack /* istanbul ignore next */ reply.send(error) } } 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 ) } reply.callNotFound() } }) 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) { 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)) { 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) } if (opts.preCompressed && !checkedEncodings.has(encoding)) { checkedEncodings.add(encoding) return pumpSendToReply( request, reply, pathnameOrig, rootPath, rootPathOffset, undefined, checkedEncodings ) } return reply.callNotFound() } // 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() } reply.send(err) }) // we cannot use pump, because send error // handling is not compatible stream.pipe(wrap) } function setUpHeadAndGet (routeOpts, route, file, rootPath) { const toSetUp = Object.assign({}, routeOpts, { method: ['HEAD', 'GET'], url: route, handler: serveFileHandler }) toSetUp.config = toSetUp.config || {} toSetUp.config.file = file toSetUp.config.rootPath = rootPath fastify.route(toSetUp) } function serveFileHandler (req, reply) { // TODO: remove the fallback branch when bump major /* istanbul ignore next */ const routeConfig = req.routeOptions?.config || req.routeConfig pumpSendToReply(req, reply, routeConfig.file, routeConfig.rootPath) } } function normalizeRoot (root) { if (root === undefined) { return root } if (root instanceof URL && root.protocol === 'file:') { return fileURLToPath(root) } if (Array.isArray(root)) { const result = [] for (let i = 0, il = root.length; i < il; ++i) { if (root[i] instanceof URL && root[i].protocol === 'file:') { result.push(fileURLToPath(root[i])) } else { result.push(root[i]) } } return result } return root } function checkRootPathForErrors (fastify, rootPath) { if (rootPath === undefined) { throw new Error('"root" option is required') } if (Array.isArray(rootPath)) { if (!rootPath.length) { throw new Error('"root" option array requires one or more paths') } if (new Set(rootPath).size !== rootPath.length) { throw new Error( '"root" option array contains one or more duplicate paths' ) } // check each path and fail at first invalid rootPath.map((path) => checkPath(fastify, path)) return } if (typeof rootPath === 'string') { return checkPath(fastify, rootPath) } throw new Error('"root" option must be a string or array of strings') } function checkPath (fastify, rootPath) { if (typeof rootPath !== 'string') { throw new Error('"root" option must be a string') } if (path.isAbsolute(rootPath) === false) { throw new Error('"root" option must be an absolute path') } let pathStat try { pathStat = statSync(rootPath) } catch (e) { if (e.code === 'ENOENT') { fastify.log.warn(`"root" path "${rootPath}" must exist`) return } throw e } if (pathStat.isDirectory() === false) { throw new Error('"root" option must point to a directory') } } function getContentType (path) { const type = send.mime.getType(path) || send.mime.default_type if (!send.isUtf8MimeType(type)) { return type } return `${type}; charset=UTF-8` } function findIndexFile (pathname, root, indexFiles = ['index.html']) { // TODO remove istanbul ignore /* istanbul ignore else */ if (Array.isArray(indexFiles)) { return indexFiles.find(filename => { const p = path.join(root, pathname, filename) try { const stats = statSync(p) return !stats.isDirectory() } catch { return false } }) } /* istanbul ignore next */ return false } // Adapted from https://github.com/fastify/fastify-compress/blob/665e132fa63d3bf05ad37df3c20346660b71a857/index.js#L451 function getEncodingHeader (headers, checked) { if (!('accept-encoding' in headers)) return // consider the no-preference token as gzip for downstream compat const header = headers['accept-encoding'].toLowerCase().replace(asteriskRegex, 'gzip') return encodingNegotiator.negotiate( header, supportedEncodings.filter((enc) => !checked.has(enc)) ) } function getEncodingExtension (encoding) { switch (encoding) { case 'br': return 'br' case 'gzip': return 'gz' } } function getRedirectUrl (url) { let i = 0 // we detect how many slash before a valid path for (; i < url.length; ++i) { if (url[i] !== '/' && url[i] !== '\\') break } // turns all leading / or \ into a single / url = '/' + url.substr(i) try { const parsed = new URL(url, 'http://localhost.com/') const parsedPathname = parsed.pathname return parsedPathname + (parsedPathname[parsedPathname.length - 1] !== '/' ? '/' : '') + (parsed.search || '') } catch { // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack /* istanbul ignore next */ const err = new Error(`Invalid redirect URL: ${url}`) /* istanbul ignore next */ err.statusCode = 400 /* istanbul ignore next */ throw err } } module.exports = fp(fastifyStatic, { fastify: '4.x', name: '@fastify/static' }) module.exports.default = fastifyStatic module.exports.fastifyStatic = fastifyStatic