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 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 () {
|
||||||
|
@ -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)
|
||||||
|
14
lolisafe.js
14
lolisafe.js
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user