You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					510 lines
				
				14 KiB
			
		
		
			
		
	
	
					510 lines
				
				14 KiB
			| 
											3 years ago
										 | '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' | ||
|  | }) |