fix(snapshots): normalize content-type on serve so stored images render

Cameras (Hikvision) return `Content-Type: image/jpeg; charset="UTF-8"` — a
charset param on a binary body is malformed, and browsers refuse to decode an
<img> declared that way. Old capture code persisted that raw header into
snapshots.content_type (100/101 dev-DB rows); GET /api/snapshots/:id re-emitted
it verbatim, so every legacy snapshot rendered blank in the booth modal.

Capture was already hardened (encodeForStorage re-encodes to a clean
image/jpeg, fail-soft via cleanType), but the serve route trusted the stored
value. Export cleanType and apply it when setting the response header, so a
bare image/jpeg is sent regardless of what was stored — un-breaks all legacy
rows with no data migration. A stored value from an untrusted device is itself
input; normalize on capture AND on serve. Adds cleanType unit tests.

Verified: a previously-unrenderable 2560x1440 row now decodes in-browser.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-30 17:58:06 +02:00
parent 1b86750b0d
commit cfac14e09e
3 changed files with 30 additions and 5 deletions
+5 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { cleanType } from "../snapshot.js";
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
async (req, reply) => {
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
if (!row) return reply.code(404).send({ error: "no such snapshot" });
reply.header("content-type", row.contentType);
// Normalize on the way OUT too: legacy rows stored a camera's malformed
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
// the bogus params back to a bare `image/jpeg` so every stored image displays.
reply.header("content-type", cleanType(row.contentType));
reply.header("cache-control", "private, max-age=31536000, immutable");
return reply.send(row.bytes);
},