feat: serveStatic with accept-ranges support

for streaming support

and with conditional GET support
This commit is contained in:
Bobby Wibowo 2022-07-22 00:01:25 +07:00
parent 33d0428e74
commit d6020d81ae
No known key found for this signature in database
GPG Key ID: 51C3A1E1E22D26CF
3 changed files with 204 additions and 114 deletions

View File

@ -1,25 +1,57 @@
const contentDisposition = require('content-disposition') 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 SimpleDataStore = require('./../utils/SimpleDataStore')
const errors = require('./../errorsController')
const paths = require('./../pathsController') const paths = require('./../pathsController')
const utils = require('./../utilsController') const utils = require('./../utilsController')
const serveUtils = require('./../utils/serveUtils')
const logger = require('./../../logger') 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 { class ServeStatic {
directory directory
contentDispositionStore contentDispositionStore
contentTypesMaps contentTypesMaps
setContentDisposition
setContentType
async #setContentDisposition () {} #options
#setContentType () {}
constructor (directory, options = {}) { constructor (directory, options = {}) {
logger.error('new ServeStatic()') logger.debug(`new ServeStatic(): ${directory}`)
if (!directory || typeof directory !== 'string') { if (!directory || typeof directory !== 'string') {
throw new TypeError('Root directory must be set') throw new TypeError('Root directory must be set')
} }
this.directory = directory 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 // Init Content-Type overrides
if (typeof options.overrideContentTypes === 'object') { if (typeof options.overrideContentTypes === 'object') {
this.contentTypesMaps = new Map() this.contentTypesMaps = new Map()
@ -35,15 +67,14 @@ class ServeStatic {
} }
if (this.contentTypesMaps.size) { 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.) // Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.)
const relpath = path.replace(paths.uploads, '') if (req.path.indexOf('/', 1) === -1) {
if (relpath.indexOf('/', 1) === -1) { const name = req.path.substring(1)
const name = relpath.substring(1)
const extname = utils.extname(name).substring(1) const extname = utils.extname(name).substring(1)
const contentType = this.contentTypesMaps.get(extname) const contentType = this.contentTypesMaps.get(extname)
if (contentType) { 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.) // Do only if accessing files from uploads' root directory (i.e. not thumbs, etc.)
const relpath = path.replace(paths.uploads, '') if (req.path.indexOf('/', 1) !== -1) return
if (relpath.indexOf('/', 1) !== -1) return const name = req.path.substring(1)
const name = relpath.substring(1)
try { try {
let original = this.contentDispositionStore.get(name) let original = this.contentDispositionStore.get(name)
if (original === undefined) { if (original === undefined) {
@ -80,7 +110,7 @@ class ServeStatic {
}) })
} }
if (original) { if (original) {
res.set('Content-Disposition', contentDisposition(original, { type: 'inline' })) res.header('Content-Disposition', contentDisposition(original, { type: 'inline' }))
} }
} catch (error) { } catch (error) {
this.contentDispositionStore.delete(name) this.contentDispositionStore.delete(name)
@ -91,23 +121,162 @@ class ServeStatic {
logger.debug('Inititated SimpleDataStore for Content-Disposition: ' + logger.debug('Inititated SimpleDataStore for Content-Disposition: ' +
`{ limit: ${this.contentDispositionStore.limit}, strategy: "${this.contentDispositionStore.strategy}" }`) `{ limit: ${this.contentDispositionStore.limit}, strategy: "${this.contentDispositionStore.strategy}" }`)
} }
this.#options = options
} }
async #setHeaders (req, res) { async #get (fullPath) {
logger.log('ServeStatic.setHeaders()') const stat = await paths.stat(fullPath)
this.#setContentType(req, res) if (stat.isDirectory()) return
// Only set Content-Disposition on GET requests return stat
if (req.method === 'GET') { }
await this.#setContentDisposition(req, res)
/*
* 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) { async #stream (req, res, fullPath, opts) {
logger.log(`ServeStatic.middleware(): ${this.directory}, ${req.path}`) const readStream = fs.createReadStream(fullPath, opts)
// TODO readStream.on('error', error => {
readStream.destroy()
logger.error(error)
})
return res.stream(readStream)
} }
get middleware () { get middleware () {

View File

@ -1,77 +1,18 @@
const self = {} const fresh = require('fresh')
/* const self = {
* https://github.com/jshttp/fresh/blob/v0.5.2/index.js BYTES_RANGE_REGEXP: /^ *bytes=/
* 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
} }
self.isFresh = (req, res) => { self.isFresh = (req, res) => {
return self.fresh(req.headers, { return fresh(req.headers, {
etag: res.get('ETag'), etag: res.get('ETag'),
'last-modified': res.get('Last-Modified') '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) 2012 TJ Holowaychuk
* Copyright(c) 2014-2022 Douglas Christopher Wilson * Copyright(c) 2014-2022 Douglas Christopher Wilson
* MIT Licensed * MIT Licensed
@ -122,27 +63,9 @@ self.isPreconditionFailure = (req, res) => {
return false return false
} }
/* self.contentRange = (type, size, range) => {
// TODO: ServeStatic may need these, but ServeLiveDirectory does its own (since it does not need Accept-Ranges support) return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size
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.parseHttpDate = date => { self.parseHttpDate = date => {
const timestamp = date && Date.parse(date) const timestamp = date && Date.parse(date)

View File

@ -48,7 +48,7 @@ const ExpressCompat = require('./controllers/middlewares/expressCompat')
const NunjucksRenderer = require('./controllers/middlewares/nunjucksRenderer') const NunjucksRenderer = require('./controllers/middlewares/nunjucksRenderer')
const RateLimiter = require('./controllers/middlewares/rateLimiter') const RateLimiter = require('./controllers/middlewares/rateLimiter')
const ServeLiveDirectory = require('./controllers/middlewares/serveLiveDirectory') const ServeLiveDirectory = require('./controllers/middlewares/serveLiveDirectory')
// const ServeStatic = require('./controllers/middlewares/serveStatic') // TODO const ServeStatic = require('./controllers/middlewares/serveStatic')
// Routes // Routes
const album = require('./routes/album') const album = require('./routes/album')
@ -269,21 +269,19 @@ safe.use('/api', api)
}) })
// Init ServerStatic last if serving uploaded files with node // Init ServerStatic last if serving uploaded files with node
/* // TODO
if (config.serveFilesWithNode) { if (config.serveFilesWithNode) {
const serveStaticInstance = new ServeStatic(paths.uploads, { const serveStaticInstance = new ServeStatic(paths.uploads, {
contentDispositionOptions: config.contentDispositionOptions, contentDispositionOptions: config.contentDispositionOptions,
ignorePatterns: [
'/chunks/'
],
overrideContentTypes: config.overrideContentTypes, overrideContentTypes: config.overrideContentTypes,
setContentDisposition: config.setContentDisposition setContentDisposition: config.setContentDisposition
}) })
safe.use('/', serveStaticInstance.middleware) safe.get('/*', serveStaticInstance.middleware)
safe.head('/*', serveStaticInstance.middleware)
utils.contentDispositionStore = serveStaticInstance.contentDispositionStore 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) // Web server error handlers (must always be set after all routes/middlewares)
safe.set_not_found_handler(errors.handleNotFound) safe.set_not_found_handler(errors.handleNotFound)