/* * ServeStatic is intended to be used last in middlewares/handlers hierarcy, * as it has to check the physical disks everytime to lookup for files. * * Hence for lolisafe, this is meant to be used solely to serve uploaded files, * if serving files with node. * Because of that, it optionally comes with Content-Type overrides, * and database query for Content-Disposition. * * This class also has Conditional GETs support, * which involves handling cache-related headers such as * If-Match, If-Unmodified-Since, ETag, etc. * And partial bytes fetch by handling Content-Range header, * which is useful for streaming, among other things. * * For other generic assets where lookups speed is a priority, * please use ServeStaticQuick middleware. */ const contentDisposition = require('content-disposition') const etag = require('etag') const fs = require('fs') const parseRange = require('range-parser') const SimpleDataStore = require('./../utils/SimpleDataStore') const errors = require('./../errorsController') const paths = require('./../pathsController') const utils = require('./../utilsController') const serveUtils = require('./../utils/serveUtils') const logger = require('./../../logger') class ServeStatic { directory contentDispositionStore contentTypesMaps setContentDisposition setContentType #options constructor (directory, options = {}) { if (!directory || typeof directory !== 'string') { throw new TypeError('Root directory must be set') } this.directory = serveUtils.forwardSlashes(directory) // Ensure does not end with a forward slash if (this.directory.endsWith('/')) { this.directory = this.directory.slice(0, -1) } if (options.acceptRanges === undefined) { options.acceptRanges = true } if (options.etag === undefined) { options.etag = true } if (options.ignorePatterns) { if (!Array.isArray(options.ignorePatterns) || options.ignorePatterns.some(pattern => typeof pattern !== 'string')) { throw new TypeError('Middleware option ignorePatterns must be an array of string') } } if (options.lastModified === undefined) { options.lastModified = true } if (options.setHeaders && typeof options.setHeaders !== 'function') { throw new TypeError('Middleware option setHeaders must be a function') } // Init Content-Type overrides if (typeof options.overrideContentTypes === 'object') { this.contentTypesMaps = new Map() const types = Object.keys(options.overrideContentTypes) for (const type of types) { const extensions = options.overrideContentTypes[type] if (Array.isArray(extensions)) { for (const extension of extensions) { this.contentTypesMaps.set(extension, type) } } } if (this.contentTypesMaps.size) { this.setContentType = (req, res) => { // Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.) if (req.path.indexOf('/', 1) === -1) { const name = req.path.substring(1) const extname = utils.extname(name).substring(1) const contentType = this.contentTypesMaps.get(extname) if (contentType) { res.header('Content-Type', contentType) } } } } else { this.contentTypesMaps = undefined } } // Init Content-Disposition store and setHeaders function if required if (options.setContentDisposition) { this.contentDispositionStore = new SimpleDataStore( options.contentDispositionOptions || { limit: 50, strategy: SimpleDataStore.STRATEGIES[0] } ) this.setContentDisposition = async (req, res) => { // Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.) if (req.path.indexOf('/', 1) !== -1) return const name = req.path.substring(1) try { let original = this.contentDispositionStore.get(name) if (original === undefined) { this.contentDispositionStore.hold(name) original = await utils.db.table('files') .where('name', name) .select('original') .first() .then(_file => { this.contentDispositionStore.set(name, _file.original) return _file.original }) } if (original) { res.header('Content-Disposition', contentDisposition(original, { type: 'inline' })) } } catch (error) { this.contentDispositionStore.delete(name) logger.error(error) } } logger.debug('Inititated SimpleDataStore for Content-Disposition: ' + `{ limit: ${this.contentDispositionStore.limit}, strategy: "${this.contentDispositionStore.strategy}" }`) } this.#options = options } async #get (fullPath) { const stat = await paths.stat(fullPath) if (stat.isDirectory()) return return stat } /* * Based on https://github.com/pillarjs/send/blob/0.18.0/index.js * Copyright(c) 2012 TJ Holowaychuk * Copyright(c) 2014-2022 Douglas Christopher Wilson * MIT Licensed */ async #handler (req, res) { if (this.#options.ignorePatterns && this.#options.ignorePatterns.some(pattern => req.path.startsWith(pattern))) { return errors.handleNotFound(req, res) } const fullPath = this.directory + req.path const stat = await this.#get(fullPath) .catch(error => { // Only re-throw errors if not due to missing files if (error.code !== 'ENOENT') { throw error } }) if (stat === undefined) { return errors.handleNotFound(req, res) } // ReadStream options let len = stat.size const opts = {} let ranges = req.headers.range let offset = 0 // set content-type res.type(req.path) // set header fields await this.#setHeaders(req, res, stat) // conditional GET support if (serveUtils.isConditionalGET(req)) { if (serveUtils.isPreconditionFailure(req, res)) { return res.status(412).end() } if (serveUtils.isFresh(req, res)) { return res.status(304).end() } } // adjust len to start/end options len = Math.max(0, len - offset) if (opts.end !== undefined) { const bytes = opts.end - offset + 1 if (len > bytes) len = bytes } // Range support if (this.#options.acceptRanges && serveUtils.BYTES_RANGE_REGEXP.test(ranges)) { // parse ranges = parseRange(len, ranges, { combine: true }) // If-Range support if (!serveUtils.isRangeFresh(req, res)) { // range stale ranges = -2 } // unsatisfiable if (ranges === -1) { // Content-Range res.header('Content-Range', serveUtils.contentRange('bytes', len)) // 416 Requested Range Not Satisfiable return res.status(416).end() } // valid (syntactically invalid/multiple ranges are treated as a regular response) if (ranges !== -2 && ranges.length === 1) { // Content-Range res.status(206) res.header('Content-Range', serveUtils.contentRange('bytes', len, ranges[0])) // adjust for requested range offset += ranges[0].start len = ranges[0].end - ranges[0].start + 1 } } else if (req.method === 'GET' && this.setContentDisposition) { // Only set Content-Disposition on complete GET requests // Range requests are typically when streaming await this.setContentDisposition(req, res) } // set read options opts.start = offset opts.end = Math.max(offset, offset + len - 1) // HEAD support if (req.method === 'HEAD') { // If HEAD, also set Content-Length (must be string) res.header('Content-Length', String(len)) return res.end() } return this.#stream(req, res, fullPath, opts, len) } async #setHeaders (req, res, stat) { // Override Content-Type if required if (this.setContentType) { this.setContentType(req, res) } // Always do external setHeaders function first, // in case it will overwrite the following default headers anyways if (this.#options.setHeaders) { this.#options.setHeaders(req, res) } if (this.#options.acceptRanges && !res.get('Accept-Ranges')) { res.header('Accept-Ranges', 'bytes') } if (this.#options.lastModified && !res.get('Last-Modified')) { const modified = stat.mtime.toUTCString() res.header('Last-Modified', modified) } if (this.#options.etag && !res.get('ETag')) { const val = etag(stat) res.header('ETag', val) } } async #stream (req, res, fullPath, opts, len) { const readStream = fs.createReadStream(fullPath, opts) readStream.on('error', error => { readStream.destroy() logger.error(error) }) // 2nd param will be set as Content-Length header (must be number) return res.stream(readStream, len) } get handler () { return this.#handler.bind(this) } } module.exports = ServeStatic