mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Fix CCTV provider cancellation and credential-safe errors
This commit is contained in:
1 parent
1bccc295f0
commit
5b4f5ed4a0
8 files changed
+293
-63
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Array<object>>} 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,
|
||||
|
||||
@@ -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('<xml/>'));
|
||||
},
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user