'use strict' const path = require('path') const statSync = require('fs').statSync const { PassThrough } = require('readable-stream') const glob = require('glob') const send = require('send') const contentDisposition = require('content-disposition') const fp = require('fastify-plugin') const util = require('util') const globPromise = util.promisify(glob) const encodingNegotiator = require('encoding-negotiator') const dirList = require('./lib/dirList') async function fastifyStatic (fastify, opts) { 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.list) if (invalidDirListOpts) { throw invalidDirListOpts } 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 } const allowedPath = opts.allowedPath if (opts.prefix === undefined) opts.prefix = '/' let prefix = opts.prefix if (!opts.prefixAvoidTrailingSlash) { prefix = opts.prefix[opts.prefix.length - 1] === '/' ? opts.prefix : opts.prefix + '/' } function pumpSendToReply ( request, reply, pathname, rootPath, rootPathOffset = 0, pumpOptions = {}, checkedEncodings ) { 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)) { 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) } } } const stream = send(request.raw, 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.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 }).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 directoy without a trailing shash 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 }).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, pathname, rootPath, undefined, 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) } const errorHandler = (error, request, reply) => { if (error && error.code === 'ERR_STREAM_PREMATURE_CLOSE') { reply.request.raw.destroy() return } fastify.errorHandler(error, request, reply) } // Set the schema hide property if defined in opts or true by default const routeOpts = { schema: { hide: typeof opts.schemaHide !== 'undefined' ? opts.schemaHide : true }, errorHandler: fastify.errorHandler ? errorHandler : undefined } 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.head(prefix + '*', routeOpts, function (req, reply) { pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root) }) fastify.get(prefix + '*', routeOpts, function (req, reply) { pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root) }) if (opts.redirect === true && prefix !== opts.prefix) { fastify.get(opts.prefix, routeOpts, function (req, reply) { reply.redirect(301, getRedirectUrl(req.raw.url)) }) } } else { const globPattern = '**/*' const indexDirs = new Map() const routes = new Set() for (const rootPath of Array.isArray(sendOptions.root) ? sendOptions.root : [sendOptions.root]) { const files = await globPromise(path.join(rootPath, globPattern), { nodir: true }) const indexes = typeof opts.index === 'undefined' ? ['index.html'] : [].concat(opts.index) for (let file of files) { file = file .replace(rootPath.replace(/\\/g, '/'), '') .replace(/^\//, '') const route = encodeURI(prefix + file).replace(/\/\//g, '/') if (routes.has(route)) { continue } routes.add(route) fastify.head(route, routeOpts, function (req, reply) { pumpSendToReply(req, reply, '/' + file, rootPath) }) fastify.get(route, routeOpts, function (req, reply) { pumpSendToReply(req, reply, '/' + 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, '') fastify.head(pathname, routeOpts, function (req, reply) { pumpSendToReply(req, reply, file, rootPath) }) fastify.get(pathname, routeOpts, function (req, reply) { pumpSendToReply(req, reply, file, rootPath) }) if (opts.redirect === true) { fastify.head(pathname.replace(/\/$/, ''), routeOpts, function (req, reply) { pumpSendToReply(req, reply, file.replace(/\/$/, ''), rootPath) }) fastify.get(pathname.replace(/\/$/, ''), routeOpts, function (req, reply) { pumpSendToReply(req, reply, file.replace(/\/$/, ''), rootPath) }) } } } } } 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)].length !== 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') } } const supportedEncodings = ['br', 'gzip', 'deflate'] function getContentType (path) { const type = send.mime.lookup(path) const charset = send.mime.charsets.lookup(type) if (!charset) { return type } return `${type}; charset=${charset}` } function findIndexFile (pathname, root, indexFiles = ['index.html']) { return indexFiles.find(filename => { const p = path.join(root, pathname, filename) try { const stats = statSync(p) return !stats.isDirectory() } catch (e) { 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 const header = headers['accept-encoding'].toLowerCase().replace(/\*/g, '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 detech how many slash before a valid path for (i; 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/') return parsed.pathname + (parsed.pathname[parsed.pathname.length - 1] !== '/' ? '/' : '') + (parsed.search || '') } catch (error) { // 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: '3.x', name: 'fastify-static' })