diff --git a/changelog.d/1228-cctv-ontario-511-key.md b/changelog.d/1228-cctv-ontario-511-key.md index d9dc7986f..cbc3194d4 100644 --- a/changelog.d/1228-cctv-ontario-511-key.md +++ b/changelog.d/1228-cctv-ontario-511-key.md @@ -2,3 +2,5 @@ free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is otherwise skipped with one log line saying where to get a key (quanticsoul4772, #1228). +- Refuse Ontario catalog redirects and keep credential-bearing URLs and provider + exception text out of failure logs; retain the Ontario licence attribution. diff --git a/changelog.d/1233-tarktee-datex-catalog-hardening.md b/changelog.d/1233-tarktee-datex-catalog-hardening.md index e8fec8b34..f2b2781af 100644 --- a/changelog.d/1233-tarktee-datex-catalog-hardening.md +++ b/changelog.d/1233-tarktee-datex-catalog-hardening.md @@ -1,4 +1,5 @@ - Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, - cancel both response streams when either request fails or overflows the 4 MB + abort pending siblings and cancel received or late response streams when either + request fails before headers or overflows the 4 MB byte cap, retain the shared 30 s dual-XML fetch deadline, and deduplicate camera IDs before per-pack prioritization (yaka0007, #1233). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e9b6c1df5..a7ed66091 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4446,7 +4446,7 @@ silently demoting every later lookup for the session. requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one (#1228). All sources are RAW PRIOR poses, and the layer is stills-first. - sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`, + Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`, kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806 GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors @@ -4459,6 +4459,11 @@ silently demoting every later lookup for the session. empty keeps the old catalog and is retried after another 15 minutes, not on the next request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s (`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s. + Ontario catalog requests refuse redirects and log only a fixed diagnostic for + transport/JSON failures, so reflected request URLs or keys cannot reach logs. + Tarktee owns both XML requests with one 30-second deadline and shared abort; + a failed header or body read cancels its sibling, including a response arriving + after cleanup. - **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field validated 2026-07-21): the COVERAGE toggle is a tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum diff --git a/server/providers/cctv/ontario511.test.mjs b/server/providers/cctv/ontario511.test.mjs index 51334b3cb..9a8ba0e64 100644 --- a/server/providers/cctv/ontario511.test.mjs +++ b/server/providers/cctv/ontario511.test.mjs @@ -127,3 +127,43 @@ for (const status of [429, 500, 503]) { assert.doesNotMatch(lines[0], /secret-key-value/); }); } + +for (const failure of ['transport', 'invalid-json']) { + test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => { + const lines = captureConsole(t); + const key = 'synthetic secret&/=%value'; + t.mock.method(globalThis, 'fetch', async (url) => { + if (failure === 'transport') + throw new Error(`request failed: ${url} ${key}`); + return new Response(`not JSON: ${url} ${key}`); + }); + assert.deepEqual( + await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }), + [], + ); + assert.equal(lines.length, 1); + assert.match(lines[0], /Ontario 511 camera download error/); + assert.equal(lines[0].includes(key), false); + assert.equal(lines[0].includes('synthetic'), false); + assert.equal(lines[0].includes('https://'), false); + }); +} + +test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => { + const lines = captureConsole(t); + const key = 'synthetic &/=% value'; + let request; + t.mock.method(globalThis, 'fetch', async (url, init) => { + request = { url: new URL(url), init }; + return Response.json([ROW]); + }); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: key }, + ); + assert.equal(request.url.searchParams.get('key'), key); + assert.equal(request.init.redirect, 'error'); + assert.equal(cameras.length, 1); + assert.equal(JSON.stringify(cameras).includes('synthetic'), false); + assert.equal(lines.join(' ').includes('synthetic'), false); +}); diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 56d712898..9681c6362 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -563,6 +563,7 @@ export async function loadOntarioSourcesFromOpenData( url.searchParams.set('key', key); const resp = await fetch(url, { headers: { Accept: 'application/json' }, + redirect: 'error', signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), }); if (!resp.ok) { @@ -646,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData( `[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`, ); return prioritized; - } catch (error) { + } catch { + // Transport and JSON errors may reflect the credential-bearing request. + // Do not log upstream exception text, response bodies or URLs. console.warn( - '[CCTV] Ontario 511 camera download error:', - error?.message || error, + '[CCTV] Ontario 511 camera download error (network or invalid response)', ); return []; } @@ -1246,66 +1248,50 @@ export function parseTarkteeDatexImages(xml) { * @returns {Promise>} Normalized camera source objects. */ export async function loadTarkteeSourcesFromDatex() { + // Own both transports before either can reject. A response that arrives + // after cleanup must also be discarded instead of escaping Promise.all. + const abort = new AbortController(); + const signal = AbortSignal.any([ + abort.signal, + AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS), + ]); + const responses = []; + const discardBoth = async () => { + abort.abort(); + await Promise.allSettled( + responses.map((response) => response.body?.cancel()), + ); + return []; + }; + const fetchXml = async (url) => { + const response = await fetch(url, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + redirect: 'manual', + signal, + }); + if (signal.aborted) { + await Promise.allSettled([response.body?.cancel()]); + signal.throwIfAborted(); + } + responses.push(response); + if (!response.ok) { + throw new Error( + response.status >= 300 && response.status < 400 + ? 'catalog redirected; redirects are not followed' + : `catalog HTTP ${response.status}`, + ); + } + return response; + }; try { const [locResp, imgResp] = await Promise.all([ - fetch(TARKTEE_LOCATIONS_URL, { - headers: { Accept: 'application/xml,text/xml,*/*' }, - redirect: 'manual', - signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS), - }), - fetch(TARKTEE_IMAGES_URL, { - headers: { Accept: 'application/xml,text/xml,*/*' }, - redirect: 'manual', - signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS), - }), + fetchXml(TARKTEE_LOCATIONS_URL), + fetchXml(TARKTEE_IMAGES_URL), + ]); + const [locXml, imgXml] = await Promise.all([ + readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal), + readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal), ]); - // Both responses are fetched in parallel; if either is rejected or overflows - // its byte cap, both bodies must be released so neither transport leaks. - const discardBoth = async () => { - await Promise.allSettled([ - locResp?.body?.cancel(), - imgResp?.body?.cancel(), - ]); - return []; - }; - if ( - (locResp.status >= 300 && locResp.status < 400) || - (imgResp.status >= 300 && imgResp.status < 400) - ) { - console.warn( - '[CCTV] Tarktee DATEX catalog redirected; redirects are not followed', - ); - return discardBoth(); - } - if (!locResp.ok) { - console.warn('[CCTV] Tarktee locations download failed:', locResp.status); - return discardBoth(); - } - if (!imgResp.ok) { - console.warn('[CCTV] Tarktee images download failed:', imgResp.status); - return discardBoth(); - } - let locXml; - let imgXml; - const readAbort = new AbortController(); - try { - [locXml, imgXml] = await Promise.all([ - readResponseTextCapped( - locResp, - TARKTEE_MAX_CATALOG_BYTES, - readAbort.signal, - ), - readResponseTextCapped( - imgResp, - TARKTEE_MAX_CATALOG_BYTES, - readAbort.signal, - ), - ]); - } catch (error) { - readAbort.abort(); - await discardBoth(); - throw error; - } const locations = parseTarkteeDatexLocations(locXml); const images = parseTarkteeDatexImages(imgXml); if (!locations.size || !images.size) { @@ -1366,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() { ); return prioritized; } catch (error) { + await discardBoth(); console.warn( '[CCTV] Tarktee DATEX download error:', error?.message || error, diff --git a/src/data/cctvEstonia.test.mjs b/src/data/cctvEstonia.test.mjs index 9ffeb6744..7ded2cc66 100644 --- a/src/data/cctvEstonia.test.mjs +++ b/src/data/cctvEstonia.test.mjs @@ -4,6 +4,7 @@ import { TARKTEE_IMAGES_URL, TARKTEE_LOCATIONS_URL, TARKTEE_MAX_CATALOG_BYTES, + TARKTEE_FETCH_TIMEOUT_MS, } from '../../server/providers/cctv/constants.js'; import { loadTallinnSourcesFromCatalog, @@ -116,7 +117,7 @@ test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', as ), ); const cameras = await loadTarkteeSourcesFromDatex(); - assert.deepEqual(timeouts, [30_000, 30_000]); + assert.deepEqual(timeouts, [30_000]); assert.equal(cameras.length, 1); }); @@ -246,3 +247,113 @@ test('Tarktee loader cancels the sibling body when one feed fails or overflows t 'sibling stream is cancelled when the other feed exceeds the byte cap', ); }); + +for (const failingUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) { + for (const lateSibling of [false, true]) { + test(`Tarktee cancels a ${lateSibling ? 'late' : 'received'} sibling when ${failingUrl} rejects before headers`, async (t) => { + quiet(t); + let cancelled = false; + let siblingSignal; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (String(url) === failingUrl) throw new Error('headers unavailable'); + siblingSignal = init.signal; + if (lateSibling) await gate; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('')); + }, + cancel() { + cancelled = true; + }, + }), + ); + }); + assert.deepEqual(await loadTarkteeSourcesFromDatex(), []); + // Also exercise a response arriving after abort, as a queued transport + // completion can race the rejection owner even when fetch honors abort. + if (lateSibling) { + release(); + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(cancelled, true, 'every obtained body must be cancelled'); + assert.equal( + siblingSignal.aborted, + true, + 'a pending transport is aborted immediately', + ); + }); + } + test(`Tarktee aborts a pending sibling when ${failingUrl} rejects`, async (t) => { + quiet(t); + let aborted = false; + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (String(url) === failingUrl) throw new Error('headers unavailable'); + return new Promise((resolve, reject) => { + init.signal.addEventListener( + 'abort', + () => { + aborted = true; + reject(init.signal.reason); + }, + { once: true }, + ); + }); + }); + assert.deepEqual(await loadTarkteeSourcesFromDatex(), []); + assert.equal(aborted, true, 'do not wait for the 30-second deadline'); + }); +} + +test('Tarktee cancels both lengthless streams when one exceeds the byte cap', async (t) => { + quiet(t); + const cancelled = []; + t.mock.method( + globalThis, + 'fetch', + async (url) => + new Response( + new ReadableStream({ + start(controller) { + const oversized = String(url) === TARKTEE_LOCATIONS_URL; + controller.enqueue( + new Uint8Array(oversized ? TARKTEE_MAX_CATALOG_BYTES + 1 : 1), + ); + }, + cancel() { + cancelled.push(String(url)); + }, + }), + ), + ); + assert.deepEqual(await loadTarkteeSourcesFromDatex(), []); + assert.deepEqual( + cancelled.sort(), + [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL].sort(), + ); +}); + +test('Tarktee aborts pending headers as soon as its sibling returns an HTTP error', async (t) => { + quiet(t); + let aborted = false; + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (String(url) === TARKTEE_LOCATIONS_URL) + return new Response('unavailable', { status: 503 }); + return new Promise((resolve, reject) => { + init.signal.addEventListener( + 'abort', + () => { + aborted = true; + reject(init.signal.reason); + }, + { once: true }, + ); + }); + }); + assert.deepEqual(await loadTarkteeSourcesFromDatex(), []); + assert.equal(aborted, true); +}); diff --git a/src/layers/cctv/frames.test.mjs b/src/layers/cctv/frames.test.mjs index 0d03e9457..a0f14169e 100644 --- a/src/layers/cctv/frames.test.mjs +++ b/src/layers/cctv/frames.test.mjs @@ -139,3 +139,34 @@ test('same pixels at a new aspect redraw at the new fit', () => { ]); assert.equal(runtime.canvasStamp, 2); }); + +test('same pixels at a new resolution with the same fit skip the redraw', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + assert.deepEqual(load(1280, 960), []); + assert.equal(runtime.canvasStamp, 1); + assert.equal(runtime.drawnImageStamp, 2); +}); + +test('changed pixels redraw even when the fitted rectangle is unchanged', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + const changedPixels = new Uint8ClampedArray( + FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4, + ).fill(90); + runtime.signatureCtx.getImageData = () => ({ data: changedPixels }); + const calls = load(640, 480); + assert.equal(calls[0][0], 'fillRect'); + assert.deepEqual(calls[1], ['drawImage', 240, 0, 1440, 1080]); + assert.equal(runtime.canvasStamp, 2); +}); + +test('a failed pixel signature redraws rather than assuming equal content', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + runtime.signatureCtx.getImageData = () => { + throw new Error('tainted canvas'); + }; + assert.deepEqual(load(640, 480).at(-1), ['drawImage', 240, 0, 1440, 1080]); + assert.equal(runtime.canvasStamp, 2); +}); diff --git a/src/layers/cctv/rendering.test.mjs b/src/layers/cctv/rendering.test.mjs index c4b490dd2..e82650c4b 100644 --- a/src/layers/cctv/rendering.test.mjs +++ b/src/layers/cctv/rendering.test.mjs @@ -71,3 +71,56 @@ test('focus de-emphasis fades each icon from its own color', () => { ); assert.equal(state._activeFocusStyleCount, 3); }); + +test('focus release restores video hue and alpha after the style throttle', () => { + let target = {}; + const records = ['mp4', 'webm'].map((feedType) => ({ + camera: { id: feedType, feedType }, + billboard: { show: true, color: LIVE_CAMERA_COLOR }, + })); + const state = { + _enabled: true, + _viewer: { scene: {}, camera: {} }, + _records: records, + _activeCameraId: null, + _activeFocusStyleCount: 0, + _lastFocusStyleAt: -Infinity, + }; + const focus = { + focusNowMs: (nowMs) => nowMs, + getFocusTarget: () => target, + focusPassIsNeeded: (value, count) => !!value || count > 0, + advanceSpriteFocus: () => ({ + factor: target ? FOCUS_FACTOR : 1, + active: !!target, + transitioning: false, + }), + focusAlphaNeedsWrite: (before, after) => before !== after, + }; + const { refreshCctvFocusStyles } = createRendering({ + state, + services: { focus }, + parts: { model: { isVideoFeedType } }, + source: {}, + }); + + refreshCctvFocusStyles(1000); + for (const record of records) { + assertFadedFrom( + record.billboard.color, + LIVE_CAMERA_COLOR, + record.camera.id, + ); + } + const dimmed = records.map((record) => record.billboard.color); + target = null; + refreshCctvFocusStyles(1050); + for (let i = 0; i < records.length; i += 1) { + assert.equal(records[i].billboard.color, dimmed[i]); + } + refreshCctvFocusStyles(1100); + for (const record of records) { + assert.deepEqual(record.billboard.color, LIVE_CAMERA_COLOR); + } + assert.equal(state._activeFocusStyleCount, 0); +});