diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts index ec413cc..b545a3d 100644 --- a/apps/server/src/routes/snapshots.ts +++ b/apps/server/src/routes/snapshots.ts @@ -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 { 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); }, diff --git a/apps/server/src/snapshot.test.ts b/apps/server/src/snapshot.test.ts index 81e5630..d7a4f65 100644 --- a/apps/server/src/snapshot.test.ts +++ b/apps/server/src/snapshot.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import sharp from "sharp"; import type { CameraDevice, Snapshot } from "@parking/devices"; -import { captureSnapshotShared, encodeForStorage } from "./snapshot.js"; +import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js"; import { silentLogger } from "./test-helpers.js"; // captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves @@ -139,3 +139,20 @@ describe("encodeForStorage", () => { expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback }); }); + +describe("cleanType", () => { + it("strips a camera's charset cruft so a binary JPEG renders", () => { + // The exact malformed value some cameras (Hikvision) return, which broke the + // snapshot strip for every legacy row until the serve route normalized it. + expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg"); + expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg"); + }); + + it("passes a clean type through and defaults a missing one", () => { + expect(cleanType("image/jpeg")).toBe("image/jpeg"); + expect(cleanType("image/png")).toBe("image/png"); + expect(cleanType(null)).toBe("image/jpeg"); + expect(cleanType(undefined)).toBe("image/jpeg"); + expect(cleanType("")).toBe("image/jpeg"); + }); +}); diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index a708ed3..bd07572 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -40,9 +40,13 @@ import type { VisionClient } from "./vision-client.js"; const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280); const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80); -/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */ -function cleanType(ct: string): string { - const base = ct.split(";")[0]?.trim(); +/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare + * `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g. + * Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied + * both on capture AND when serving, so legacy rows stored before this normalization + * existed still serve a clean type. */ +export function cleanType(ct: string | null | undefined): string { + const base = ct?.split(";")[0]?.trim(); return base || "image/jpeg"; }