mirror of
https://github.com/BobbyWibowo/lolisafe.git
synced 2025-01-18 17:21:33 +00:00
feat: ServeStaticQuick
chokidar is now a production dependency please read the comments in ServeStaticQuick.js for a description of what the class does public and dist directories are now served with that class by default before starting hyper-express on the listen port, await for all ServeLiveDirectory and ServeStaticQuick instances
This commit is contained in:
parent
d40d1e396f
commit
2389974c7d
@ -95,6 +95,11 @@ class ServeLiveDirectory {
|
||||
return res.send(file.buffer)
|
||||
}
|
||||
|
||||
ready () {
|
||||
// Returns a promise which resolves to true once LiveDirectory instance is ready
|
||||
return this.instance.ready()
|
||||
}
|
||||
|
||||
#middleware (req, res, next) {
|
||||
// Only process GET and HEAD requests
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
|
276
controllers/middlewares/ServeStaticQuick.js
Normal file
276
controllers/middlewares/ServeStaticQuick.js
Normal file
@ -0,0 +1,276 @@
|
||||
/*
|
||||
* ServeStaticQuick.
|
||||
*
|
||||
* This is intended as a compromise between ServeStatic handler and
|
||||
* ServeLiveDirectory middleware.
|
||||
*
|
||||
* This monitors and caches the configured directory's file tree for quick lookups,
|
||||
* thus allowing multiple instances of this middleware to be used together, if needed.
|
||||
*
|
||||
* When matches are found, it will then simply spawn ReadStream to the physical files.
|
||||
* Due to the fact that it does not have to pre-cache the whole files into memory,
|
||||
* this is likely the better choice to serve generic assets
|
||||
* in an environment where memory space is a premium.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const chokidar = require('chokidar')
|
||||
const etag = require('etag')
|
||||
const fs = require('fs')
|
||||
const parseRange = require('range-parser')
|
||||
const serveUtils = require('./../utils/serveUtils')
|
||||
const logger = require('./../../logger')
|
||||
|
||||
class ServeStaticQuick {
|
||||
directory
|
||||
files
|
||||
watcher
|
||||
|
||||
#options
|
||||
#readyPromise
|
||||
#readyResolve
|
||||
|
||||
constructor (directory, options = {}) {
|
||||
if (!directory || typeof directory !== 'string') {
|
||||
throw new TypeError('Root directory must be set')
|
||||
}
|
||||
|
||||
this.directory = serveUtils.forwardSlashes(directory)
|
||||
|
||||
// Ensure does not end with a forward slash
|
||||
if (this.directory.endsWith('/')) {
|
||||
this.directory = this.directory.slice(0, -1)
|
||||
}
|
||||
|
||||
if (options.acceptRanges === undefined) {
|
||||
options.acceptRanges = true
|
||||
}
|
||||
|
||||
if (options.etag === undefined) {
|
||||
options.etag = true
|
||||
}
|
||||
|
||||
if (options.lastModified === undefined) {
|
||||
options.lastModified = true
|
||||
}
|
||||
|
||||
if (options.setHeaders && typeof options.setHeaders !== 'function') {
|
||||
throw new TypeError('Middleware option setHeaders must be a function')
|
||||
}
|
||||
|
||||
this.files = new Map()
|
||||
|
||||
this.watcher = chokidar.watch(this.directory, {
|
||||
alwaysStat: true,
|
||||
awaitWriteFinish: {
|
||||
pollInterval: 100,
|
||||
stabilityThreshold: 500
|
||||
}
|
||||
})
|
||||
|
||||
this.#bindWatchHandlers()
|
||||
|
||||
this.#options = options
|
||||
}
|
||||
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
handler (req, res, stat) {
|
||||
// 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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// set read options
|
||||
opts.start = offset
|
||||
opts.end = Math.max(offset, offset + len - 1)
|
||||
|
||||
// HEAD support
|
||||
if (req.method === 'HEAD') {
|
||||
// If HEAD, also set Content-Length (must be string)
|
||||
res.header('Content-Length', String(len))
|
||||
return res.end()
|
||||
}
|
||||
|
||||
if (len === 0) {
|
||||
res.end()
|
||||
}
|
||||
|
||||
return this.#stream(req, res, stat, opts, len)
|
||||
}
|
||||
|
||||
// Returns a promise which resolves to true once ServeStaticQuick is ready
|
||||
ready () {
|
||||
// Resolve with true if ready is not a promise
|
||||
if (this.#readyPromise === true) return Promise.resolve(true)
|
||||
|
||||
// Create a promise if one does not exist for ready event
|
||||
if (this.#readyPromise === undefined) { this.#readyPromise = new Promise((resolve) => (this.#readyResolve = resolve)) }
|
||||
|
||||
return this.#readyPromise
|
||||
}
|
||||
|
||||
#bindWatchHandlers () {
|
||||
this.watcher.on('all', (event, path, stat) => {
|
||||
const relPath = serveUtils.relativePath(this.directory, path)
|
||||
|
||||
if (!relPath) return // skips root directory
|
||||
|
||||
switch (event) {
|
||||
case 'add':
|
||||
case 'addDir':
|
||||
case 'change':
|
||||
this.files.set(relPath, stat)
|
||||
break
|
||||
case 'unlink':
|
||||
case 'unlinkDir':
|
||||
this.files.delete(relPath)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Bind 'ready' for when all files have been loaded
|
||||
this.watcher.once('ready', () => {
|
||||
// Resolve pending promise if one exists
|
||||
if (typeof this.#readyResolve === 'function') {
|
||||
this.#readyResolve()
|
||||
this.#readyResolve = null
|
||||
}
|
||||
|
||||
// Mark instance as ready
|
||||
this.#readyPromise = true
|
||||
})
|
||||
}
|
||||
|
||||
#get (path) {
|
||||
const stat = this.files.get(path)
|
||||
|
||||
if (!stat || stat.isDirectory()) return
|
||||
|
||||
return stat
|
||||
}
|
||||
|
||||
#middleware (req, res, next) {
|
||||
// Only process GET and HEAD requests
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return next()
|
||||
}
|
||||
|
||||
const stat = this.#get(req.path)
|
||||
if (stat === undefined) {
|
||||
return next()
|
||||
}
|
||||
|
||||
return this.handler(req, res, stat)
|
||||
}
|
||||
|
||||
#setHeaders (req, res, stat) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
#stream (req, res, stat, opts, len) {
|
||||
const fullPath = this.directory + req.path
|
||||
const readStream = fs.createReadStream(fullPath, opts)
|
||||
|
||||
readStream.on('error', error => {
|
||||
readStream.destroy()
|
||||
logger.error(error)
|
||||
})
|
||||
|
||||
// 2nd param will be set as Content-Length header (must be number)
|
||||
return res.stream(readStream, len)
|
||||
}
|
||||
|
||||
get middleware () {
|
||||
return this.#middleware.bind(this)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ServeStaticQuick
|
18
lolisafe.js
18
lolisafe.js
@ -47,6 +47,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 ServeStaticQuick = require('./controllers/middlewares/ServeStaticQuick')
|
||||
|
||||
// Handlers
|
||||
const ServeStatic = require('./controllers/handlers/ServeStatic')
|
||||
@ -190,17 +191,17 @@ if (config.cacheControl) {
|
||||
})
|
||||
}
|
||||
|
||||
// Init LiveDirectory middlewares for static assets
|
||||
// Init ServeStaticQuick middlewares for static assets
|
||||
// Static assets in /public directory
|
||||
const serveLiveDirectoryPublicInstance = new ServeLiveDirectory(paths.public, {
|
||||
const serveStaticQuickPublicInstance = new ServeStaticQuick(paths.public, {
|
||||
setHeaders: setHeadersForStaticAssets
|
||||
})
|
||||
safe.use(serveLiveDirectoryPublicInstance.middleware)
|
||||
safe.use(serveStaticQuickPublicInstance.middleware)
|
||||
// Static assets in /dist directory
|
||||
const serveLiveDirectoryDistInstance = new ServeLiveDirectory(paths.dist, {
|
||||
const serveStaticQuickDistInstance = new ServeStaticQuick(paths.dist, {
|
||||
setHeaders: setHeadersForStaticAssets
|
||||
})
|
||||
safe.use(serveLiveDirectoryDistInstance.middleware)
|
||||
safe.use(serveStaticQuickDistInstance.middleware)
|
||||
|
||||
// Routes
|
||||
safe.use(album)
|
||||
@ -310,6 +311,13 @@ safe.use('/api', api)
|
||||
logger.log(`Connection established with ${utils.scan.version}`)
|
||||
}
|
||||
|
||||
// Await all ServeLiveDirectory and ServeStaticQuick instances
|
||||
await Promise.all([
|
||||
serveStaticQuickDistInstance.ready(),
|
||||
serveStaticQuickPublicInstance.ready(),
|
||||
serveLiveDirectoryCustomPagesInstance.ready()
|
||||
])
|
||||
|
||||
// Binds Express to port
|
||||
await safe.listen(utils.conf.port)
|
||||
logger.log(`lolisafe started on port ${utils.conf.port}`)
|
||||
|
@ -37,6 +37,7 @@
|
||||
"bcrypt": "~5.0.1",
|
||||
"better-sqlite3": "~7.6.2",
|
||||
"blake3": "~2.1.7",
|
||||
"chokidar": "~3.5.3",
|
||||
"clamscan": "~2.1.2",
|
||||
"content-disposition": "~0.5.4",
|
||||
"etag": "~1.8.1",
|
||||
@ -62,7 +63,6 @@
|
||||
"@ronilaukkarinen/gulp-stylelint": "~14.0.6",
|
||||
"browserslist": "~4.21.3",
|
||||
"bulma": "~0.9.4",
|
||||
"chokidar": "~3.5.3",
|
||||
"cssnano": "~5.1.12",
|
||||
"del": "~6.1.1",
|
||||
"eslint": "~8.20.0",
|
||||
|
Loading…
Reference in New Issue
Block a user