diff --git a/server.js b/server.js index 07308af..67d2f00 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,7 @@ import express from 'express'; -import { randomUUID } from 'node:crypto'; -import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { randomUUID, createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, extname, normalize, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { authRoutes, requireAuth } from './auth.js'; @@ -178,6 +178,46 @@ function findOr404(list, id, res, what) { return item; } +// ---------- Versionierte Seiten ---------- +// Proxys (z. B. Nginx Proxy Manager mit "Cache Assets") und Browser halten CSS/JS lange vor. Damit nach einem +// Update nie alte Dateien mit neuen gemischt werden, hängt der Server an jede CSS/JS-Adresse in HTML und an +// jeden Modul-Import ?v= an. HTML und JS selbst werden immer neu geprüft. +const ASSET_DIRS = ['public/css', 'public/js', 'private/js'].map((d) => join(ROOT, d)); +let assetVersion = { key: '', value: '' }; + +function currentAssetVersion() { + const files = ASSET_DIRS.flatMap((dir) => (existsSync(dir) ? readdirSync(dir).map((f) => join(dir, f)) : [])) + .filter((f) => /\.(css|js)$/.test(f)).sort(); + // Nur neu rechnen, wenn sich Dateien geändert haben (Änderungszeit + Größe) + const key = files.map((f) => { const st = statSync(f); return `${f}:${st.mtimeMs}:${st.size}`; }).join('|'); + if (key !== assetVersion.key) { + const hash = createHash('sha1'); + for (const f of files) hash.update(readFileSync(f)); + assetVersion = { key, value: hash.digest('hex').slice(0, 10) }; + } + return assetVersion.value; +} + +function versioned(dir, cacheControl) { + return (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return next(); + let path; + try { path = decodeURIComponent(req.path); } catch { return next(); } + if (path.endsWith('/')) path += 'index.html'; + let file = normalize(join(dir, path)); + if (!file.startsWith(dir + sep)) return next(); + if (!extname(file)) file += '.html'; + if (!/\.(html|js)$/.test(file) || !existsSync(file) || !statSync(file).isFile()) return next(); + const v = currentAssetVersion(); + const text = readFileSync(file, 'utf8') + .replace(/((?:href|src)="\/(?:css|js)\/[\w.-]+\.(?:css|js))"/g, `$1?v=${v}"`) + .replace(/(from\s+'\.\/[\w.-]+\.js)'/g, `$1?v=${v}'`); + res.type(extname(file)); + res.setHeader('Cache-Control', cacheControl); + res.send(text); + }; +} + // ---------- App ---------- const app = express(); app.disable('x-powered-by'); @@ -189,6 +229,7 @@ app.use('/api/patches', express.json({ limit: '12mb' })); app.use(express.json({ limit: '1mb' })); // Öffentlich: Startseite, Login, CSS/Bilder +app.use(versioned(join(ROOT, 'public'), 'no-cache')); app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] })); authRoutes(app); @@ -309,6 +350,7 @@ app.delete('/api/patches/:id', (req, res) => { saveDb(); res.status(204).end(); }); +app.use(versioned(join(ROOT, 'private'), 'private, no-cache')); app.use(express.static(join(ROOT, 'private'), { extensions: ['html'], setHeaders: (res) => res.setHeader('Cache-Control', 'private, no-cache'),