From 5f0a7bf6cc8bd0f4ff7517232f15ab0b25a647c5 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 31 Jul 2026 11:21:31 +0200 Subject: [PATCH] server: RFC7807 404s without a client build; document STATS_PUBLIC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three items from #54 (item 1, the dead is_return column, is a drop-or-use product call left for mara — see issue comment): 1. The RFC7807 404 handler for /api and /admin/api was only registered inside the 'if (existsSync(clientDist))' branch, so a server-only deploy (or any run before 'pnpm --filter client build') fell through to Fastify's default 404 shape instead — quietly opting API routes out of the one-error-shape guarantee the RFC7807 conversion was for. Split registration from the static-file serving: the not-found handler (and its API-vs-SPA-fallback branching) is now unconditional, only the SPA html sendFile calls stay gated on hasClientDist. 2. STATS_PUBLIC added to the README's env-var list (was only in deploy/wutzcalc.env.example) — an operator wanting the infopoint- screen setup wouldn't find the switch, and an operator auditing "what can expose data here" from the README wouldn't see it exists. Verified live: renamed client/dist away and confirmed /api, /admin/api, and a bare unmatched route all return RFC7807 problem+json (no crash); restored it and confirmed the SPA fallback (admin.html/stats.html/ index.html) still serves correctly. tsc --noEmit and server build both clean. --- README.md | 4 ++++ server/src/index.ts | 44 ++++++++++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 77260c3..ca51ae2 100644 --- a/README.md +++ b/README.md @@ -170,3 +170,7 @@ the service user owns the database directory automatically. - `WUTZ_SERVER_PORT` (default `3000`, **client dev only** — not read by the server) — the port `dev:client`'s Vite proxy targets; set it to match `dev:server`'s `PORT` when running the server on something other than the default +- `STATS_PUBLIC` (default off, set to `1` to enable) — makes `/stats` viewable without the admin + login normally required (e.g. a screen permanently mounted at a festival infopoint). Doesn't + affect `/admin` itself, or the "Statistik zurücksetzen" reset button, which stays gated on a real + admin session either way. diff --git a/server/src/index.ts b/server/src/index.ts index 5b22df9..1a302bc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -90,31 +90,43 @@ app.get('/healthz', async () => ({ ok: true })); // Static client (built by Vite into client/dist) const clientDist = join(__dirname, '..', '..', 'client', 'dist'); -if (existsSync(clientDist)) { +const hasClientDist = existsSync(clientDist); +if (hasClientDist) { await app.register(fastifyStatic, { root: clientDist, prefix: '/', wildcard: false, }); - app.setNotFoundHandler((req, reply) => { - if (req.url.startsWith('/api') || req.url.startsWith('/admin/api')) { - sendProblem(req, reply, 404, 'Not Found', 'not found'); - return; - } - if (req.url.startsWith('/admin')) { - reply.sendFile('admin.html'); - return; - } - if (req.url.startsWith('/stats')) { - reply.sendFile('stats.html'); - return; - } - reply.sendFile('index.html'); - }); } else { app.log.warn(`client dist not found at ${clientDist} — run \`pnpm --filter client build\``); } +// Registered unconditionally — previously nested inside the `hasClientDist` +// branch above, so a server-only deploy (or any run before the client is +// built) fell through to Fastify's default 404 shape on /api and +// /admin/api instead of RFC 7807, quietly opting API routes out of the +// one-error-shape guarantee the RFC 7807 conversion was for. The +// SPA-fallback file sends below still need the client build to exist. +app.setNotFoundHandler((req, reply) => { + if (req.url.startsWith('/api') || req.url.startsWith('/admin/api')) { + sendProblem(req, reply, 404, 'Not Found', 'not found'); + return; + } + if (!hasClientDist) { + sendProblem(req, reply, 404, 'Not Found', 'not found'); + return; + } + if (req.url.startsWith('/admin')) { + reply.sendFile('admin.html'); + return; + } + if (req.url.startsWith('/stats')) { + reply.sendFile('stats.html'); + return; + } + reply.sendFile('index.html'); +}); + try { await app.listen({ port: PORT, host: HOST }); app.log.info(`listening on http://${HOST}:${PORT}`);