2022-07-31 07:06:17 +00:00
|
|
|
/*
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
|
2022-07-11 23:29:21 +00:00
|
|
|
const contentDisposition = require('content-disposition')
|
2022-07-21 17:01:25 +00:00
|
|
|
const etag = require('etag')
|
|
|
|
const fs = require('fs')
|
2022-07-12 01:51:22 +00:00
|
|
|
const SimpleDataStore = require('./../utils/SimpleDataStore')
|
2022-07-21 17:01:25 +00:00
|
|
|
const errors = require('./../errorsController')
|
2022-07-12 01:51:22 +00:00
|
|
|
const paths = require('./../pathsController')
|
|
|
|
const utils = require('./../utilsController')
|
2022-07-21 17:01:25 +00:00
|
|
|
const serveUtils = require('./../utils/serveUtils')
|
2022-07-12 01:51:22 +00:00
|
|
|
const logger = require('./../../logger')
|
2022-07-11 23:29:21 +00:00
|
|
|
|
|
|
|
class ServeStatic {
|
|
|
|
directory
|
|
|
|
contentDispositionStore
|
|
|
|
contentTypesMaps
|
|
|
|
|
2022-07-21 17:01:25 +00:00
|
|
|
#options
|
2022-07-11 23:29:21 +00:00
|
|
|
|
|
|
|
constructor (directory, options = {}) {
|
|
|
|
if (!directory || typeof directory !== 'string') {
|
|
|
|
throw new TypeError('Root directory must be set')
|
|
|
|
}
|
|
|
|
|
2022-07-31 07:06:17 +00:00
|
|
|
this.directory = serveUtils.forwardSlashes(directory)
|
|
|
|
|
|
|
|
// Ensure does not end with a forward slash
|
|
|
|
if (this.directory.endsWith('/')) {
|
|
|
|
this.directory = this.directory.slice(0, -1)
|
|
|
|
}
|
2022-07-11 23:29:21 +00:00
|
|
|
|
2022-07-21 17:01:25 +00:00
|
|
|
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')
|
|
|
|
}
|
|
|
|
|
2022-08-01 00:29:49 +00:00
|
|
|
// Init Content-Type overrides map if required
|
2022-07-11 23:29:21 +00:00
|
|
|
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) {
|
2022-09-20 23:09:27 +00:00
|
|
|
logger.debug(`Initiated Content-Type overrides map for ${this.contentTypesMaps.size} extension(s).`)
|
2022-07-11 23:29:21 +00:00
|
|
|
} else {
|
|
|
|
this.contentTypesMaps = undefined
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-01 00:29:49 +00:00
|
|
|
// Init Content-Disposition store if required
|
2022-07-11 23:29:21 +00:00
|
|
|
if (options.setContentDisposition) {
|
|
|
|
this.contentDispositionStore = new SimpleDataStore(
|
|
|
|
options.contentDispositionOptions || {
|
|
|
|
limit: 50,
|
|
|
|
strategy: SimpleDataStore.STRATEGIES[0]
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
2022-09-20 23:09:27 +00:00
|
|
|
logger.debug('Initiated SimpleDataStore for Content-Disposition: ' +
|
2022-07-11 23:29:21 +00:00
|
|
|
`{ limit: ${this.contentDispositionStore.limit}, strategy: "${this.contentDispositionStore.strategy}" }`)
|
|
|
|
}
|
2022-07-21 17:01:25 +00:00
|
|
|
|
|
|
|
this.#options = options
|
|
|
|
}
|
|
|
|
|
|
|
|
async #get (fullPath) {
|
|
|
|
const stat = await paths.stat(fullPath)
|
|
|
|
|
|
|
|
if (stat.isDirectory()) return
|
|
|
|
|
|
|
|
return stat
|
|
|
|
}
|
|
|
|
|
2022-07-21 18:12:35 +00:00
|
|
|
async #handler (req, res) {
|
2022-07-21 17:01:25 +00:00
|
|
|
if (this.#options.ignorePatterns && this.#options.ignorePatterns.some(pattern => req.path.startsWith(pattern))) {
|
|
|
|
return errors.handleNotFound(req, res)
|
|
|
|
}
|
|
|
|
|
2022-07-31 07:06:17 +00:00
|
|
|
const fullPath = this.directory + req.path
|
2022-07-21 17:01:25 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2022-07-31 09:34:06 +00:00
|
|
|
// Set Content-Type
|
2022-07-21 17:01:25 +00:00
|
|
|
res.type(req.path)
|
|
|
|
|
2022-07-31 09:34:06 +00:00
|
|
|
// Set header fields
|
2022-07-21 17:01:25 +00:00
|
|
|
await this.#setHeaders(req, res, stat)
|
|
|
|
|
2022-07-31 09:34:06 +00:00
|
|
|
// Conditional GET support
|
|
|
|
if (serveUtils.assertConditionalGET(req, res)) {
|
|
|
|
return res.end()
|
2022-07-21 17:01:25 +00:00
|
|
|
}
|
|
|
|
|
2022-07-31 09:34:06 +00:00
|
|
|
// ReadStream options with Content-Range support if required
|
2022-07-31 09:46:35 +00:00
|
|
|
const result = serveUtils.buildReadStreamOptions(req, res, stat, this.#options.acceptRanges)
|
|
|
|
if (!result) {
|
|
|
|
return res.end()
|
|
|
|
}
|
2022-07-21 17:01:25 +00:00
|
|
|
|
|
|
|
// HEAD support
|
|
|
|
if (req.method === 'HEAD') {
|
2022-07-29 03:16:49 +00:00
|
|
|
// If HEAD, also set Content-Length (must be string)
|
2022-07-31 09:46:35 +00:00
|
|
|
res.header('Content-Length', String(result.length))
|
2022-07-21 17:01:25 +00:00
|
|
|
return res.end()
|
|
|
|
}
|
|
|
|
|
2022-07-31 09:34:06 +00:00
|
|
|
// Only set Content-Disposition on initial GET request
|
|
|
|
// Skip for subsequent requests on non-zero start byte (e.g. streaming)
|
2022-08-01 00:29:49 +00:00
|
|
|
if (result.options.start === 0 && this.contentDispositionStore) {
|
|
|
|
await this.#setContentDisposition(req, res)
|
2022-07-31 09:34:06 +00:00
|
|
|
}
|
|
|
|
|
2022-07-31 09:46:35 +00:00
|
|
|
if (result.length === 0) {
|
2022-07-31 07:17:06 +00:00
|
|
|
res.end()
|
|
|
|
}
|
|
|
|
|
2022-07-31 09:46:35 +00:00
|
|
|
return this.#stream(req, res, fullPath, result)
|
2022-07-11 23:29:21 +00:00
|
|
|
}
|
|
|
|
|
2022-08-01 00:29:49 +00:00
|
|
|
async #setContentDisposition (req, res) {
|
|
|
|
// Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.)
|
|
|
|
if (req.path.indexOf('/', 1) !== -1) return
|
|
|
|
|
|
|
|
// Encapsulate within try-catch block because we do not want these to throw
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async #setContentType (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)
|
|
|
|
const extname = utils.extname(name).substring(1)
|
|
|
|
const contentType = this.contentTypesMaps.get(extname)
|
|
|
|
if (contentType) {
|
|
|
|
// NOTE: Use lowercase key because the header initially set
|
|
|
|
// with Response.type() is also lowercase
|
|
|
|
res.header('content-type', contentType)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-21 17:01:25 +00:00
|
|
|
async #setHeaders (req, res, stat) {
|
|
|
|
// Override Content-Type if required
|
2022-08-01 00:29:49 +00:00
|
|
|
if (this.contentTypesMaps) {
|
|
|
|
this.#setContentType(req, res)
|
2022-07-21 17:01:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
}
|
2022-07-11 23:29:21 +00:00
|
|
|
|
2022-07-21 17:01:25 +00:00
|
|
|
if (this.#options.acceptRanges && !res.get('Accept-Ranges')) {
|
|
|
|
res.header('Accept-Ranges', 'bytes')
|
|
|
|
}
|
2022-07-11 23:29:21 +00:00
|
|
|
|
2022-07-21 17:01:25 +00:00
|
|
|
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)
|
2022-07-11 23:29:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-31 09:46:35 +00:00
|
|
|
async #stream (req, res, fullPath, result) {
|
|
|
|
const readStream = fs.createReadStream(fullPath, result.options)
|
2022-07-21 17:01:25 +00:00
|
|
|
|
|
|
|
readStream.on('error', error => {
|
|
|
|
readStream.destroy()
|
|
|
|
logger.error(error)
|
|
|
|
})
|
2022-07-11 23:29:21 +00:00
|
|
|
|
2022-07-29 03:16:49 +00:00
|
|
|
// 2nd param will be set as Content-Length header (must be number)
|
2022-07-31 09:46:35 +00:00
|
|
|
return res.stream(readStream, result.length)
|
2022-07-11 23:29:21 +00:00
|
|
|
}
|
|
|
|
|
2022-07-21 18:12:35 +00:00
|
|
|
get handler () {
|
|
|
|
return this.#handler.bind(this)
|
2022-07-11 23:29:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = ServeStatic
|