diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 1d9875b..55fb5fa 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -879,3 +879,20 @@ {"id":"evt-fqxcxhh97c1x","type":"edge.added","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-wbk0v4t","to":"iss-ws9nb88"},"at":"2026-09-19T22:07:39.760Z","parents":["evt-pts2yq477k34"],"hash":"7f61fcd22e5c71adf48faee46fab3f7f23f551e669c17a95472868216792ea7b"} {"id":"evt-gr6gmbpx3d72","type":"verification.recorded","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User's dev server log on 2026-09-19 17:06: \"[motion] watching 2 camera(s), recording the sub stream\", then for both cameras \"[motion] recording \", MediaMTX \"[recorder] recording … tracks\", and \"[motion] stopped recording \" after the post-roll. Files on disk: .data/recordings/cam--sub-rec/2026-09-19_17-06-*.mp4, 1.2–2.5 MB each; box inspection shows valid fMP4 (ftyp, moov, moof/mdat fragments, avc1)."},"at":"2026-09-19T22:07:39.761Z","parents":["evt-fqxcxhh97c1x"],"hash":"82786785d155aa9499945f9306118813533d1c9bb409d50dc905c034faf65869"} {"id":"evt-gsf60sb8qp1v","type":"node.status_changed","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T22:07:43.955Z","parents":["evt-gr6gmbpx3d72"],"hash":"5f7bb9c68f09bb12cc86d905793efc99f10974c05845cdbfb112429ff33e1094"} +{"id":"evt-s5abwkxw1wm1","type":"node.created","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A session-checked Recordings page lists clips per camera newest first with time and size, plays them with seeking (Range requests), and deletes after a confirmation; clip ids are matched against fixed patterns and resolved inside the recordings folder, so no request can reach another file; the camera page links to its own clips","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:13:30.531Z","parents":["evt-gsf60sb8qp1v"],"hash":"cabdfc8bd5463c8acb6c382e14e8e1a7d26f15a68a13504cc9069374ce55cd0c"} +{"id":"evt-n01s9wvxbbp7","type":"edge.added","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-ncyy24g","to":"iss-v677bbd"},"at":"2026-09-19T22:13:30.533Z","parents":["evt-s5abwkxw1wm1"],"hash":"58a3ffe785a9456e3d9773a387a4cb9f1855e601c43608c790c3cbb9e12898b6"} +{"id":"evt-1hgpgz7w83rh","type":"verification.recorded","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/recordings.test.ts (listClips, clipFile traversal refusals, deleteClip), src/app/api/recordings/route.test.ts, src/app/api/recordings/clip/route.test.ts (full, range, suffix, 416, traversal), src/app/recordings/clip-list.test.tsx, src/app/recordings/page.test.tsx. Full suite 861 tests pass, 99.67% lines; tsc and eslint clean; next build lists ƒ /recordings, /api/recordings and /api/recordings/clip."},"at":"2026-09-19T22:13:30.534Z","parents":["evt-n01s9wvxbbp7"],"hash":"fbc9a2e411cb1de6b54f186f1a27a332ef6d651adf4eef1f72f7df69ff93a51e"} +{"id":"evt-m27ze50m7ew2","type":"node.created","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: a motion clip from each camera plays in the browser on /recordings, and deleting one works","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:13:32.598Z","parents":["evt-1hgpgz7w83rh"],"hash":"c5ef657ba791dd92636ecf742dd6248d77f79c49d20ebb65e8a8715f2432f861"} +{"id":"evt-rgavza6239gj","type":"edge.added","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-aexbfgn","to":"iss-v677bbd"},"at":"2026-09-19T22:13:32.599Z","parents":["evt-m27ze50m7ew2"],"hash":"9a6583fb04bbebc781101bc5a5d3ac538e45048ea2d0c39234968880eddd51ff"} +{"id":"evt-8jf435p6dswr","type":"verification.recorded","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test after restarting the server: open /recordings, play a clip from each camera, delete one"},"at":"2026-09-19T22:13:32.600Z","parents":["evt-rgavza6239gj"],"hash":"4abc418fc138f427492f611f43968dcbbfa9a74f93bc4148c7aacb4abcfca654"} +{"id":"evt-bmcdhjathg9p","type":"node.status_changed","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T22:13:34.671Z","parents":["evt-8jf435p6dswr"],"hash":"035e51380b988013c77356b04ed50ea7bf55555138e45d969fccaca0e15ca6df"} +{"id":"evt-cyh6g3kt46hq","type":"node.created","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Recorded G711 audio from camera …af54 does play in the browser, alongside H.264 video","body":"Corrects the caveat in fnd-y7h6pyf, which assumed browsers could not decode G711 inside MP4. On 2026-09-19 the user played motion clips on /recordings and reported that audio worked. So MediaMTX's fMP4 recordings of a G711 (A-law) track are playable in the user's browser as written, with no transcoding, and no audio conversion is needed for playback. Camera …af54 publishes H264 plus G711 on its sub stream; …af2e publishes video only. Untested: whether other browsers behave the same, and whether audio also arrives over the live WebRTC player.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-v677bbd","note":"User's own playback on /recordings: \"this works well and audio seems to work too\""}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:15:57.638Z","parents":["evt-bmcdhjathg9p"],"hash":"2334e4b4fe34f75664847d726b4a3c702108b6ca6d1fdcc9f5ae51c32e5167c4"} +{"id":"evt-pvzz20apprz9","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"que-ph8eak5"},"at":"2026-09-19T22:15:57.639Z","parents":["evt-cyh6g3kt46hq"],"hash":"6624e96f367c3a826ba21949c49f3814cc8ae61e22b7110fe04f5efc055d4971"} +{"id":"evt-8nwppxpaj613","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"iss-td8y1cc"},"at":"2026-09-19T22:15:57.640Z","parents":["evt-pvzz20apprz9"],"hash":"c4741584dd29cd005f2c0ecf7f3cdcfe61426abf482ef3664d29d70657b68877"} +{"id":"evt-k5wqxg4b8t29","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"gol-sxakryh"},"at":"2026-09-19T22:15:57.641Z","parents":["evt-8nwppxpaj613"],"hash":"3165e4ebf290ddec762097f256959ee4b0b5b6393808d69d2e532d86925b9e54"} +{"id":"evt-kgedg91yjbht","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"supersedes","from":"fnd-0qyrzm9","to":"fnd-y7h6pyf"},"at":"2026-09-19T22:15:57.642Z","parents":["evt-k5wqxg4b8t29"],"hash":"f9a55af6f43f68b49eac03d7b09fe302480032fcd3f66ab9429da2204b9d95d5"} +{"id":"evt-kxff6phn2yet","type":"node.status_changed","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"current","to":"superseded"},"at":"2026-09-19T22:15:57.643Z","parents":["evt-kgedg91yjbht"],"hash":"bba33d5a64fcf42c2a472d188960e9fc659f97d2a1cae7b53ba1748632607756"} +{"id":"evt-q6y6kzp0x3rt","type":"node.created","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: motion clips play in the browser on /recordings","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:16:00.004Z","parents":["evt-kxff6phn2yet"],"hash":"bacbde6fdba262183500c21401a8349287c68fb122a0ed4e2b075b4f0142bb47"} +{"id":"evt-xah5c4gbg51b","type":"edge.added","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-60m8ttp","to":"iss-v677bbd"},"at":"2026-09-19T22:16:00.005Z","parents":["evt-q6y6kzp0x3rt"],"hash":"5fc6c47a5ee1452b99e37a9e03b0535035c0b7d7501c54a831a3abc321bdeab5"} +{"id":"evt-tvjs688dbkvd","type":"verification.recorded","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User on 2026-09-19: \"this works well and audio seems to work too\" after opening /recordings and playing clips."},"at":"2026-09-19T22:16:00.006Z","parents":["evt-xah5c4gbg51b"],"hash":"3d1b87655feb999a73e54d75a528a625c905420e09c088f434a4034416ccfd63"} +{"id":"evt-rd8yca1xcevk","type":"node.status_changed","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T22:16:01.993Z","parents":["evt-tvjs688dbkvd"],"hash":"7a448702e6738eb96202a1e82f57a913a64dfb18b154a98b2fcd0246879c0f68"} diff --git a/src/app/api/recordings/clip/route.test.ts b/src/app/api/recordings/clip/route.test.ts new file mode 100644 index 0000000..898c5ee --- /dev/null +++ b/src/app/api/recordings/clip/route.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Access control is tested in src/app/api/access.test.ts; here requests are allowed. +vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null })); + +const { GET, DELETE } = await import("./route"); + +const ID = "11111111-2222-3333-4444-555555555555"; +const NAME = "2026-09-19_17-06-18-101298.mp4"; +const CLIP = `cam-${ID}-sub-rec/${NAME}`; +const BODY = "0123456789"; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "clip-route-")); + vi.stubEnv("RECORDINGS_DIR", dir); + const folder = path.join(dir, `cam-${ID}-sub-rec`); + await (await import("node:fs/promises")).mkdir(folder, { recursive: true }); + await writeFile(path.join(folder, NAME), BODY); +}); +afterEach(() => rm(dir, { recursive: true, force: true })); + +const url = (clip = CLIP) => `http://localhost/api/recordings/clip?clip=${encodeURIComponent(clip)}`; +const get = (clip?: string, headers?: HeadersInit) => GET(new Request(url(clip), { headers })); +const text = async (res: Response) => Buffer.from(await res.arrayBuffer()).toString(); + +describe("GET /api/recordings/clip", () => { + it("serves the whole clip as seekable video", async () => { + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("video/mp4"); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); + expect(res.headers.get("Content-Length")).toBe(String(BODY.length)); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(await text(res)).toBe(BODY); + }); + + it("serves a byte range so the player can seek", async () => { + const res = await get(CLIP, { range: "bytes=2-5" }); + expect(res.status).toBe(206); + expect(res.headers.get("Content-Range")).toBe(`bytes 2-5/${BODY.length}`); + expect(res.headers.get("Content-Length")).toBe("4"); + expect(await text(res)).toBe("2345"); + }); + + it("serves an open-ended range and a suffix range", async () => { + expect(await text(await get(CLIP, { range: "bytes=7-" }))).toBe("789"); + expect(await text(await get(CLIP, { range: "bytes=-3" }))).toBe("789"); + }); + + it("refuses a range past the end", async () => { + const res = await get(CLIP, { range: "bytes=50-60" }); + expect(res.status).toBe(416); + expect(res.headers.get("Content-Range")).toBe(`bytes */${BODY.length}`); + }); + + it.each([ + ["a traversal", "../../etc/passwd"], + ["a folder that isn't ours", `secrets/${NAME}`], + ["a name that isn't a clip", `cam-${ID}-sub-rec/passwd`], + ["a clip that doesn't exist", `cam-${ID}-main-rec/${NAME}`], + ["nothing", ""], + ])("is not found for %s", async (_l, clip) => { + expect((await get(clip)).status).toBe(404); + }); +}); + +describe("DELETE /api/recordings/clip", () => { + it("deletes the clip", async () => { + expect((await DELETE(new Request(url(), { method: "DELETE" }))).status).toBe(204); + expect((await get()).status).toBe(404); + }); + + it.each([["../../etc/passwd"], [""], [`cam-${ID}-sub-rec/2026-01-01_00-00-00-000000.mp4`]])( + "is not found for %j", + async (clip) => { + expect((await DELETE(new Request(url(clip), { method: "DELETE" }))).status).toBe(404); + }, + ); +}); diff --git a/src/app/api/recordings/clip/route.ts b/src/app/api/recordings/clip/route.ts new file mode 100644 index 0000000..01685d8 --- /dev/null +++ b/src/app/api/recordings/clip/route.ts @@ -0,0 +1,64 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import type { ReadableOptions } from "node:stream"; +import { apiAccessDenied } from "@/lib/access"; +import { clipFile, deleteClip } from "@/lib/recordings"; + +/** + * Plays or deletes one motion clip (vrek iss-v677bbd). The ?clip= id is matched against + * fixed patterns and resolved inside the recordings folder, so a request can only name a + * clip that folder actually holds (pri-m1csgrm). + */ + +function fileFor(request: Request): string | null { + return clipFile(new URL(request.url).searchParams.get("clip") ?? ""); +} + +/** Node's read stream as a web stream, for one byte range. */ +function bodyFor(file: string, options: ReadableOptions & { start?: number; end?: number }) { + return createReadStream(file, options) as unknown as ReadableStream; +} + +export async function GET(request: Request) { + const denied = await apiAccessDenied(); + if (denied) return denied; + const file = fileFor(request); + const info = file ? await stat(file).catch(() => null) : null; + if (!info?.isFile()) return Response.json({ error: "No such clip" }, { status: 404 }); + + const headers: Record = { + "Content-Type": "video/mp4", + "Cache-Control": "no-store", + "Accept-Ranges": "bytes", + }; + + // Seeking in a