From 59a20e2d18bb4a6353410bb21d3cf6a9ec3c472c Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Sat, 12 Sep 2026 16:34:51 -0700 Subject: [PATCH] fix(cctv): cap buffered snapshot downloads Adapt the streamed snapshot size cap contributed in public PR #82 to the CCTV media module. --- server/providers/cctv/constants.js | 3 +++ server/providers/cctv/media.js | 35 ++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 2ab5f5715..81e293fdc 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -38,3 +38,6 @@ export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000; * client refresh cadence. A bounded miss can fall through to Street View or * the synthetic frame instead of leaving the browser preview pending. */ export const CCTV_FRAME_FETCH_TIMEOUT_MS = 8 * 1000; + +/** Maximum buffered snapshot size. */ +export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024; diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index e7684dacc..adc052f81 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -1,6 +1,6 @@ import { Readable } from 'node:stream'; import { hashSeed, escapeXml } from './normalize.js'; -import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './constants.js'; +import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_FRAME_MAX_BODY_BYTES } from './constants.js'; /** * Generate a synthetic SVG billboard image for a CCTV camera placeholder. * @@ -166,9 +166,32 @@ export async function proxyMediaResponse( * @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout. * @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>} */ +async function readCappedResponseBytes(upstream, maxBytes) { + const declared = Number(upstream.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + try { await upstream.body?.cancel(); } catch { /* no-op */ } + return null; + } + if (!upstream.body || typeof upstream.body[Symbol.asyncIterator] !== 'function') { + const buffered = Buffer.from(await upstream.arrayBuffer()); + return buffered.length > maxBytes ? null : buffered; + } + const chunks = []; + let total = 0; + for await (const chunk of upstream.body) { + total += chunk.length; + if (total > maxBytes) { + try { await upstream.body.cancel(); } catch { /* no-op */ } + return null; + } + chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + } + return Buffer.concat(chunks, total); +} + export async function fetchCctvImageFromUpstream( url, - { fetchImpl = fetch, timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS } = {}, + { fetchImpl = fetch, timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, maxBytes = CCTV_FRAME_MAX_BODY_BYTES } = {}, ) { if (!url || !/^https?:\/\//i.test(url)) return null; const controller = new AbortController(); @@ -184,11 +207,9 @@ export async function fetchCctvImageFromUpstream( }); const contentType = upstream.headers.get('content-type') || ''; if (!upstream.ok || !contentType.startsWith('image/')) return null; - return { - ok: true, - body: Buffer.from(await upstream.arrayBuffer()), - contentType, - }; + const body = await readCappedResponseBytes(upstream, maxBytes); + if (!body) return null; + return { ok: true, body, contentType }; } catch { return null; } finally {