const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); const config = require('./config.js'); const { verifyToken } = require('./auth.js'); // compression is a new dependency (added to package.json). The Docker image // bakes node_modules at build time and routes/utils/server.js are bind-mounted // in compose.yml — but node_modules is NOT bind-mounted, so a "quick" deploy // (cp + restart) hits the old image without compression installed. We require // it defensively: present after the next image rebuild → ~70 % wire-size // reduction; absent → degrade silently to the previous uncompressed behaviour. let compression = null; try { compression = require('compression'); } catch (_) {} function requireAuth(req, res, next) { const token = req.cookies?.libreportal_token; if (!token) return res.status(401).json({ error: 'Unauthorized' }); const payload = verifyToken(token); if (!payload) return res.status(401).json({ error: 'Token expired or invalid' }); req.user = payload; next(); } // Prevent the browser from caching authenticated /data/* responses so they're // not retained after logout or persisted to disk caches. function noStore(req, res, next) { res.setHeader('Cache-Control', 'no-store'); next(); } // Static-asset options: // - 60s maxAge + ETag on JS/CSS/icons. Long enough that rapid in-session // clicks skip the network round-trip, short enough that a deploy is // visible within a minute. Originally tried 1h but that caused stale // cached JS to reference things the new HTML no longer loaded (the // Phase-B lazy-load refactor changed who loads which script). // - HTML files get Cache-Control: no-cache (always revalidates via ETag, // so new deploys land immediately — the SPA shell changes most often). // - dotfiles='ignore' so .auth.json is never served. const staticOptions = { maxAge: '60s', etag: true, dotfiles: 'ignore', setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache'); } } }; function setup(app) { // Gzip-compress responses. JS/CSS/HTML/JSON typically shrink ~70 %, so the // 1.7 MB of static assets the SPA loads on a cold cache drop to ~500 KB on // the wire. compression defaults skip already-compressed types (images, // gzipped tarballs) and small responses (<1 KB). Defensive — no-op if the // module isn't installed (image not yet rebuilt with the new dep). if (compression) app.use(compression()); app.use(express.json()); app.use(cookieParser()); // Block MIME sniffing on every response. app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); }); // /data/* requires auth. express.static doesn't generate directory listings, // so the only way to read anything is to know an exact path. noStore wins // over staticOptions' maxAge for this prefix — auth-sensitive content // should never be cached. app.use('/data', requireAuth, noStore, express.static(path.join(config.FRONTEND_PATH, 'data'))); // All other static assets (js, css, icons, html partials, index.html) remain public. app.use(express.static(config.FRONTEND_PATH, staticOptions)); } module.exports = { setup, requireAuth };