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
						
					
					
				| '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'
 | |
| })
 |