diff --git a/controllers/middlewares/serveStatic.js b/controllers/middlewares/serveStatic.js index 852bbc4..04225be 100644 --- a/controllers/middlewares/serveStatic.js +++ b/controllers/middlewares/serveStatic.js @@ -1,25 +1,57 @@ const contentDisposition = require('content-disposition') +const etag = require('etag') +const fs = require('fs') +const parseRange = require('range-parser') +const path = require('path') 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') +// NOTE: This middleware must be set last if on a root path that is also used by other routes + class ServeStatic { directory contentDispositionStore contentTypesMaps + setContentDisposition + setContentType - async #setContentDisposition () {} - #setContentType () {} + #options constructor (directory, options = {}) { - logger.error('new ServeStatic()') + logger.debug(`new ServeStatic(): ${directory}`) + if (!directory || typeof directory !== 'string') { throw new TypeError('Root directory must be set') } this.directory = directory + 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() @@ -35,15 +67,14 @@ class ServeStatic { } if (this.contentTypesMaps.size) { - this.#setContentType = (res, path, stat) => { + this.setContentType = (req, res) => { // Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.) - const relpath = path.replace(paths.uploads, '') - if (relpath.indexOf('/', 1) === -1) { - const name = relpath.substring(1) + 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.set('Content-Type', contentType) + res.header('Content-Type', contentType) } } } @@ -61,11 +92,10 @@ class ServeStatic { } ) - this.#setContentDisposition = async (res, path, stat) => { + this.setContentDisposition = async (req, res) => { // Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.) - const relpath = path.replace(paths.uploads, '') - if (relpath.indexOf('/', 1) !== -1) return - const name = relpath.substring(1) + if (req.path.indexOf('/', 1) !== -1) return + const name = req.path.substring(1) try { let original = this.contentDispositionStore.get(name) if (original === undefined) { @@ -80,7 +110,7 @@ class ServeStatic { }) } if (original) { - res.set('Content-Disposition', contentDisposition(original, { type: 'inline' })) + res.header('Content-Disposition', contentDisposition(original, { type: 'inline' })) } } catch (error) { this.contentDispositionStore.delete(name) @@ -91,23 +121,162 @@ class ServeStatic { logger.debug('Inititated SimpleDataStore for Content-Disposition: ' + `{ limit: ${this.contentDispositionStore.limit}, strategy: "${this.contentDispositionStore.strategy}" }`) } + + this.#options = options } - async #setHeaders (req, res) { - logger.log('ServeStatic.setHeaders()') + async #get (fullPath) { + const stat = await paths.stat(fullPath) - this.#setContentType(req, res) + if (stat.isDirectory()) return - // Only set Content-Disposition on GET requests - if (req.method === 'GET') { - await this.#setContentDisposition(req, res) + 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 #middleware (req, res) { + if (this.#options.ignorePatterns && this.#options.ignorePatterns.some(pattern => req.path.startsWith(pattern))) { + return errors.handleNotFound(req, res) + } + + const fullPath = path.join(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) + + // content-length + res.header('Content-Length', String(len)) + + // HEAD support + if (req.method === 'HEAD') { + return res.end() + } + + return this.#stream(req, res, fullPath, opts) + } + + 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 #middleware (req, res, next) { - logger.log(`ServeStatic.middleware(): ${this.directory}, ${req.path}`) + async #stream (req, res, fullPath, opts) { + const readStream = fs.createReadStream(fullPath, opts) - // TODO + readStream.on('error', error => { + readStream.destroy() + logger.error(error) + }) + + return res.stream(readStream) } get middleware () { diff --git a/controllers/utils/serveUtils.js b/controllers/utils/serveUtils.js index 2ca1f9b..37687da 100644 --- a/controllers/utils/serveUtils.js +++ b/controllers/utils/serveUtils.js @@ -1,77 +1,18 @@ -const self = {} +const fresh = require('fresh') -/* -* https://github.com/jshttp/fresh/blob/v0.5.2/index.js -* Copyright(c) 2012 TJ Holowaychuk -* Copyright(c) 2016-2017 Douglas Christopher Wilson -* MIT Licensed -*/ - -const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/ - -self.fresh = (reqHeaders, resHeaders) => { - // fields - const modifiedSince = reqHeaders['if-modified-since'] - const noneMatch = reqHeaders['if-none-match'] - - // unconditional request - if (!modifiedSince && !noneMatch) { - return false - } - - // Always return stale when Cache-Control: no-cache - // to support end-to-end reload requests - // https://tools.ietf.org/html/rfc2616#section-14.9.4 - const cacheControl = reqHeaders['cache-control'] - if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) { - return false - } - - // if-none-match - if (noneMatch && noneMatch !== '*') { - const etag = resHeaders.etag - - if (!etag) { - return false - } - - let etagStale = true - const matches = self.parseTokenList(noneMatch) - for (let i = 0; i < matches.length; i++) { - const match = matches[i] - if (match === etag || match === 'W/' + etag || 'W/' + match === etag) { - etagStale = false - break - } - } - - if (etagStale) { - return false - } - } - - // if-modified-since - if (modifiedSince) { - const lastModified = resHeaders['last-modified'] - const modifiedStale = !lastModified || !(self.parseHttpDate(lastModified) <= self.parseHttpDate(modifiedSince)) - - if (modifiedStale) { - return false - } - } - - return true +const self = { + BYTES_RANGE_REGEXP: /^ *bytes=/ } self.isFresh = (req, res) => { - return self.fresh(req.headers, { + return fresh(req.headers, { etag: res.get('ETag'), 'last-modified': res.get('Last-Modified') }) } /* - * https://github.com/pillarjs/send/blob/0.18.0/index.js + * 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 @@ -122,27 +63,9 @@ self.isPreconditionFailure = (req, res) => { return false } -/* -// TODO: ServeStatic may need these, but ServeLiveDirectory does its own (since it does not need Accept-Ranges support) -self.setHeader = (res, path, stat) => { - if (this._acceptRanges && !res.get('Accept-Ranges')) { - logger.debug('accept ranges') - res.header('Accept-Ranges', 'bytes') - } - - if (this._lastModified && !res.get('Last-Modified')) { - const modified = stat.mtime.toUTCString() - logger.debug('modified %s', modified) - res.header('Last-Modified', modified) - } - - if (this._etag && !res.get('ETag')) { - const val = etag(stat) - logger.debug('etag %s', val) - res.header('ETag', val) - } +self.contentRange = (type, size, range) => { + return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size } -*/ self.parseHttpDate = date => { const timestamp = date && Date.parse(date) diff --git a/lolisafe.js b/lolisafe.js index 17a8f43..8629a31 100644 --- a/lolisafe.js +++ b/lolisafe.js @@ -48,7 +48,7 @@ const ExpressCompat = require('./controllers/middlewares/expressCompat') const NunjucksRenderer = require('./controllers/middlewares/nunjucksRenderer') const RateLimiter = require('./controllers/middlewares/rateLimiter') const ServeLiveDirectory = require('./controllers/middlewares/serveLiveDirectory') -// const ServeStatic = require('./controllers/middlewares/serveStatic') // TODO +const ServeStatic = require('./controllers/middlewares/serveStatic') // Routes const album = require('./routes/album') @@ -269,21 +269,19 @@ safe.use('/api', api) }) // Init ServerStatic last if serving uploaded files with node - /* // TODO if (config.serveFilesWithNode) { const serveStaticInstance = new ServeStatic(paths.uploads, { contentDispositionOptions: config.contentDispositionOptions, + ignorePatterns: [ + '/chunks/' + ], overrideContentTypes: config.overrideContentTypes, setContentDisposition: config.setContentDisposition }) - safe.use('/', serveStaticInstance.middleware) + safe.get('/*', serveStaticInstance.middleware) + safe.head('/*', serveStaticInstance.middleware) utils.contentDispositionStore = serveStaticInstance.contentDispositionStore } - */ - if (config.serveFilesWithNode) { - logger.error('Serving files with node is currently not available in this branch.') - return process.exit(1) - } // Web server error handlers (must always be set after all routes/middlewares) safe.set_not_found_handler(errors.handleNotFound)