cfac14e09e
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
129 lines
5.6 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
}
|