mirror of
https://github.com/BobbyWibowo/lolisafe.git
synced 2025-01-31 07:11:33 +00:00
feat: serveStatic with accept-ranges support
for streaming support and with conditional GET support
This commit is contained in:
parent
33d0428e74
commit
d6020d81ae
@ -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 () {
|
||||
|
@ -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)
|
||||
|
14
lolisafe.js
14
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)
|
||||
|
Loading…
Reference in New Issue
Block a user