/** * The production web server. * * The site is `output: 'static'`: `pnpm build` prerenders every page and this process * only hands the result out over HTTP. nginx sits in front of it and proxies, rather * than pointing a `root` at the built tree, so that nothing outside this file decides * what is readable. That is not a stylistic preference — it removes two whole classes * of failure the file-serving arrangement kept producing: * * traversal permissions nginx runs as www-data and the build runs as cashumints, * so every directory from / down to dist had to be traversable * by a user with no other business in it. One 0700 home * directory anywhere in the chain took the site down, and * `try_files` reports a permission error as a plain miss, so * the symptom was a blanket 404 or an internal-redirect loop * ending in 500 — never the actual cause. * * duplicated routing the locale 404 rule lived in nginx as a hand-maintained * alternation of 23 codes. Adding a language meant editing a * file that is not in this repository, and forgetting to was * silent. Locale 404s are resolved by looking in the built * tree now, so the list cannot drift. * * Reading files is all it does. There is no template, no database handle and no route * table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards * them there directly. * * Env: * SITE_PORT 8789 port to listen on * SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS * WEB_ROOT ./dist the tree to serve */ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); function int(raw, fallback) { const n = Number.parseInt(raw ?? '', 10); return Number.isFinite(n) && n > 0 ? n : fallback; } /** * The tree to serve, absolute. * * Defaults to the build output next to this file, which is what `pnpm start` and the * tests use. In production it points at a copy outside the checkout: `pnpm build` * empties dist before it writes, so serving dist directly means a rebuild takes the * whole site down for the length of the build. See cashumints-web.service. */ export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist')); const PORT = int(process.env.SITE_PORT, 8789); const HOST = process.env.SITE_HOST ?? '127.0.0.1'; /** One line per event, key=value after the message. Matches the API's log format. */ function log(level, msg, fields = {}) { const parts = [new Date().toISOString(), level.toUpperCase(), msg]; for (const [k, v] of Object.entries(fields)) { if (v !== undefined && v !== null) parts.push(`${k}=${v}`); } const line = parts.join(' '); if (level === 'error') console.error(line); else console.log(line); } const TYPES = new Map(Object.entries({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.webmanifest': 'application/manifest+json; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.wasm': 'application/wasm', })); const IMMUTABLE = 'public, max-age=31536000, immutable'; const WEEK = 'public, max-age=604800'; const HOUR = 'public, max-age=3600'; /** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */ const REVALIDATE = 'public, max-age=0, must-revalidate'; /** * How long a response may be reused. * * Everything Vite and the card builder emit carries a content hash in its filename, so * those are immutable for a year — a changed file is a changed URL. Markup is the * opposite: the URLs are permanent and the bytes change on every rebuild, so it * revalidates every time and the ETag below turns that into a 304 in the usual case. * * `/og/default.png` is the one unhashed card, served for pages that have no mint of * their own, so it gets an hour rather than a year. */ export function cacheControl(urlPath, ext) { if (ext === '.html') return REVALIDATE; if (urlPath === '/og/default.png') return HOUR; if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE; if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR; return WEEK; } /** * Turn a request path into a path inside ROOT, or null if it escapes or is malformed. * * Returns the *relative* path so the caller can join it against ROOT; every segment is * checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are * refused outright: a static build emits none, so a request for one is either a probe * or a mistake, and neither should be answered with bytes. */ export function safePath(urlPath) { if (urlPath.includes('\0')) return null; const normalized = path.posix.normalize(urlPath); if (!normalized.startsWith('/')) return null; const segments = normalized.split('/').filter(Boolean); for (const segment of segments) { if (segment === '..' || segment.startsWith('.')) return null; } return segments; } async function statFile(file) { try { const stats = await fsp.stat(file); return stats.isFile() ? stats : null; } catch { return null; } } /** * The candidates for one request path, in order. * * Astro emits directory-style routes — /mints is dist/mints/index.html — and * `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the * literal path first keeps assets a single stat; the `.html` candidate covers the flat * files at the root, /404.html among them. */ export function candidates(segments) { const rel = segments.join('/'); if (rel === '') return ['index.html']; return [rel, `${rel}/index.html`, `${rel}.html`]; } /** * The 404 body for a path, and it is not always the English one. * * A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into * English, which is the same reason `redirectToDefaultLocale` is false in the Astro * config. Which prefixes count is decided by what the build actually emitted: if * /404/index.html exists, the prefix is a locale. Nothing to keep in sync. */ async function notFoundBody(segments) { const first = segments[0]; if (first) { const localized = path.join(ROOT, first, '404', 'index.html'); const stats = await statFile(localized); if (stats) return { file: localized, stats }; } const fallback = path.join(ROOT, '404.html'); const stats = await statFile(fallback); return stats ? { file: fallback, stats } : null; } /** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */ function etagFor(stats) { return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`; } /** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */ function etagMatches(header, etag) { if (!header) return false; if (header.trim() === '*') return true; const bare = etag.replace(/^W\//, ''); return header .split(',') .map((candidate) => candidate.trim().replace(/^W\//, '')) .includes(bare); } function notModified(req, etag, lastModified) { if (etagMatches(req.headers['if-none-match'], etag)) return true; // Only consulted when the client sent no ETag, per RFC 9110 §13.1.3. if (req.headers['if-none-match']) return false; const since = Date.parse(req.headers['if-modified-since'] ?? ''); return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since; } /** * Headers every response carries. * * No CSP here on purpose. The site loads an analytics script from another origin, the * review islands open websockets to whatever relays are configured and the status * islands fetch whatever mint URLs the index holds, so a policy tight enough to be * worth having has to be derived from those lists rather than guessed at — and a wrong * one fails as a silently broken island. HSTS belongs to nginx, which is what actually * terminates TLS. */ function baseHeaders() { return { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY', }; } function send(res, status, headers, body) { res.writeHead(status, { ...baseHeaders(), ...headers }); res.end(body); } /** Stream a file, or just its headers for HEAD. */ function sendFile(req, res, status, file, stats, urlPath) { const ext = path.extname(file).toLowerCase(); const etag = etagFor(stats); const headers = { 'Content-Type': TYPES.get(ext) ?? 'application/octet-stream', 'Content-Length': stats.size, 'Last-Modified': new Date(stats.mtimeMs).toUTCString(), ETag: etag, 'Cache-Control': cacheControl(urlPath, ext), ...baseHeaders(), }; // A 304 must not carry a body or a Content-Length describing one. if (status === 200 && notModified(req, etag, stats.mtimeMs)) { delete headers['Content-Length']; delete headers['Content-Type']; res.writeHead(304, headers); res.end(); return; } res.writeHead(status, headers); if (req.method === 'HEAD') { res.end(); return; } const stream = fs.createReadStream(file); stream.on('error', (err) => { log('error', 'read failed', { file, err: err.message }); res.destroy(); }); // Kill the read when the client hangs up mid-transfer rather than draining the file. res.on('close', () => stream.destroy()); stream.pipe(res); } async function handle(req, res) { if (req.method !== 'GET' && req.method !== 'HEAD') { send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n'); return; } let urlPath; try { urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); } catch { send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n'); return; } const segments = safePath(urlPath); if (!segments) { send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n'); return; } for (const candidate of candidates(segments)) { const file = path.join(ROOT, candidate); // Belt and braces: safePath already refused `..`, this refuses anything that still // resolved outside the tree, a symlink in the build output included. if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break; const stats = await statFile(file); if (stats) { sendFile(req, res, 200, file, stats, urlPath); return; } } const miss = await notFoundBody(segments); if (!miss) { send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n'); return; } // Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd // URL indexed as a real page. sendFile(req, res, 404, miss.file, miss.stats, urlPath); } export function createServer() { const server = http.createServer((req, res) => { handle(req, res).catch((err) => { log('error', 'request failed', { path: req.url, err: err.message }); if (!res.headersSent) { send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n'); } else { res.destroy(); } }); }); /* * Longer than nginx's upstream keepalive, and headersTimeout longer still. * * If this end closes an idle connection at the same moment nginx reuses it, nginx has * nothing to retry and reports 502. Outlasting the proxy makes the proxy always the * one to close, which is the race-free direction. */ server.keepAliveTimeout = 65_000; server.headersTimeout = 66_000; // A malformed request line should not take the process with it. server.on('clientError', (err, socket) => { if (err.code === 'ECONNRESET' || !socket.writable) return; socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); }); return server; } /** * Refuse to start on an empty or unreadable root. * * The failure this prevents is the one that is hardest to see: a process that starts * cleanly, answers every request with a 404 and looks healthy to anything watching the * port. Exiting non-zero puts the reason in `systemctl status` instead. */ async function checkRoot() { const index = path.join(ROOT, 'index.html'); if (await statFile(index)) return; log('error', 'web root has no index.html', { root: ROOT, hint: 'run pnpm build, then publish it to WEB_ROOT' }); process.exit(1); } /** Only when run directly, so the tests can import the pieces above. */ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await checkRoot(); const server = createServer(); server.listen(PORT, HOST, () => { log('info', 'site listening', { host: HOST, port: PORT, root: ROOT }); }); let shuttingDown = false; for (const signal of ['SIGTERM', 'SIGINT']) { process.on(signal, () => { if (shuttingDown) return; shuttingDown = true; log('info', 'shutting down', { signal }); server.close(() => process.exit(0)); // Idle keep-alive connections would otherwise hold the close open for a minute. server.closeIdleConnections(); setTimeout(() => { server.closeAllConnections(); process.exit(0); }, 10_000).unref(); }); } }