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:
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { cleanType } from "../snapshot.js";
|
||||||
|
|
||||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
// 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
|
// 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) => {
|
async (req, reply) => {
|
||||||
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
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" });
|
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");
|
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||||
return reply.send(row.bytes);
|
return reply.send(row.bytes);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import type { CameraDevice, Snapshot } from "@parking/devices";
|
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";
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
// 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
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,9 +40,13 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||||
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||||
|
|
||||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
|
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare
|
||||||
function cleanType(ct: string): string {
|
* `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g.
|
||||||
const base = ct.split(";")[0]?.trim();
|
* 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";
|
return base || "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user