Files
parking_solution/apps/server/src/routes/snapshots.ts
T
julian cfac14e09e 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
2026-06-30 17:58:06 +02:00

129 lines
5.6 KiB
TypeScript

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
// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them
// next to the event. Read-only — images are written only by the flows (snapshot.ts),
// never via the API.
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
const guard = requirePermission("session:read");
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
// first — lets the UI show "entry/exit image" links beside an event. We also return
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
// camera that was offline from a direction that simply has no camera — otherwise a
// missing shot is a silent gap. See snapshot.ts (recordFailure).
app.get<{ Params: { identity: string } }>(
"/api/snapshots/by-identity/:identity",
{ preHandler: guard },
async (req) => {
const identity = req.params.identity;
const rows = db
.select({
id: snapshots.id,
direction: snapshots.direction,
deviceId: snapshots.deviceId,
identity: snapshots.identity,
contentType: snapshots.contentType,
capturedAt: snapshots.capturedAt,
})
.from(snapshots)
.where(eq(snapshots.identity, identity))
.orderBy(desc(snapshots.capturedAt))
.all();
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
// detail.ok === false. There may be both a failure and (on a retry) a success
// for the same direction; we keep only failures with NO successful shot in the
// same direction, so a recovered capture doesn't show a stale warning.
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
const telemetry = db
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const failures: {
direction: "entry" | "exit" | null;
deviceId: string;
error: string;
occurredAt: string;
}[] = [];
const seenFailDir = new Set<string>();
for (const row of telemetry) {
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
if (d.identity !== identity || d.ok !== false) continue;
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
const dirKey = dir ?? "both";
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
seenFailDir.add(dirKey);
failures.push({
direction: dir,
deviceId: row.deviceId ?? "",
error: d.error ?? "capture failed",
occurredAt: row.occurredAt ?? "",
});
}
// Recognized PLATES for this session: kind="read" telemetry from the ANPR-on-
// snapshot path (snapshot.ts → recognizePlate). Advisory — a record of the plate
// observed for the session, shown beside the image. Newest first.
const plateRows = db
.select({ detail: deviceEvents.detail, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const plates: {
plate: string;
confidence: number | null;
region: string | null;
direction: "entry" | "exit" | null;
snapshotId: string | null;
at: string;
}[] = [];
for (const row of plateRows) {
const d = (row.detail ?? {}) as {
identity?: string;
plate?: string;
confidence?: number;
region?: string | null;
direction?: string;
snapshotId?: string;
};
if (d.identity !== identity || !d.plate) continue;
plates.push({
plate: d.plate,
confidence: typeof d.confidence === "number" ? d.confidence : null,
region: d.region ?? null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
snapshotId: d.snapshotId ?? null,
at: row.occurredAt ?? "",
});
}
return { snapshots: rows, failures, plates };
},
);
// Stream one snapshot's image bytes by id. Returns the stored content type.
app.get<{ Params: { id: string } }>(
"/api/snapshots/:id",
{ preHandler: guard },
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" });
// 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);
},
);
}