fix(cctv): harden Tarktee DATEX catalog fetches and dedupe before cap

This commit is contained in:
yaka0007 committed 2026-10-19 01:12:18 -04:00
1 parent 93ea088f45
commit 3792d7377e
3 files changed
+194 -8

No files matched your search

+5
View File
@@ -188,6 +188,11 @@ export const TARKTEE_IMAGES_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Two DATEX XML documents download concurrently from the same origin (~5.3 s
* baseline) alongside the other packs, so the 8 s default can abort mid-stream. */
export const TARKTEE_FETCH_TIMEOUT_MS = 15_000;
export const TARKTEE_ANCHORS = [
{ lat: 59.437, lon: 24.753 }, // Tallinn
{ lat: 58.378, lon: 26.729 }, // Tartu
+50 -8
View File
@@ -42,6 +42,8 @@ import {
TARKTEE_IMAGES_URL,
TARKTEE_IMAGE_ORIGIN,
DEFAULT_TARKTEE_MAX_SOURCES,
TARKTEE_MAX_CATALOG_BYTES,
TARKTEE_FETCH_TIMEOUT_MS,
TARKTEE_ANCHORS,
DEFAULT_WARENDORF_SOURCE_FILE,
WARENDORF_IMAGE_ORIGINS,
@@ -1194,25 +1196,62 @@ export async function loadTarkteeSourcesFromDatex() {
const [locResp, imgResp] = await Promise.all([
fetch(TARKTEE_LOCATIONS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
fetch(TARKTEE_IMAGES_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
]);
// 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 [];
return discardBoth();
}
if (!imgResp.ok) {
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
return [];
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 [locXml, imgXml] = await Promise.all([
locResp.text(),
imgResp.text(),
]);
const locations = parseTarkteeDatexLocations(locXml);
const images = parseTarkteeDatexImages(imgXml);
if (!locations.size || !images.size) {
@@ -1224,6 +1263,7 @@ export async function loadTarkteeSourcesFromDatex() {
}
const cameras = [];
const seen = new Set();
for (const [locationId, loc] of locations.entries()) {
const imageUrl = images.get(locationId);
if (!imageUrl) continue;
@@ -1235,6 +1275,8 @@ export async function loadTarkteeSourcesFromDatex() {
const cameraId = numMatch
? `ee-tarktee-${numMatch[1]}`
: `ee-tarktee-${locationId}`;
if (seen.has(cameraId)) continue;
seen.add(cameraId);
cameras.push({
id: cameraId,
name: loc.name,
+139
View File
@@ -1,11 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
TARKTEE_IMAGES_URL,
TARKTEE_LOCATIONS_URL,
TARKTEE_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
parseTarkteeDatexImages,
parseTarkteeDatexLocations,
} from '../../server/providers/cctv/sources.js';
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
@@ -84,3 +96,130 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
const images = parseTarkteeDatexImages(xml);
assert.equal(images.size, 0);
});
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
quiet(t);
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
<predefinedLocation id="loc-1" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
<predefinedLocation id="loc-2" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
</predefinedLocationContainer>
</payloadPublication>
</d2LogicalModel>`;
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
</payloadPublication>
</d2LogicalModel>`;
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init?.redirect]);
const body =
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(seen, [
[TARKTEE_LOCATIONS_URL, 'manual'],
[TARKTEE_IMAGES_URL, 'manual'],
]);
assert.equal(cameras.length, 1);
assert.equal(cameras[0].id, 'ee-tarktee-42');
assert.equal(cameras[0].name, 'Tallinn Ringtee');
});
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
quiet(t);
const cancelled = { locations: false, images: false };
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
if (isLocations) cancelled.locations = true;
else cancelled.images = true;
},
});
return new Response(body, {
status: isLocations ? 302 : 200,
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
});
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
});
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
quiet(t);
let siblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
return new Response('', { status: 503 });
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
siblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
siblingCancelled,
true,
'sibling 200 stream is cancelled on 503',
);
let overflowSiblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
const padded =
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
return new Response(padded, {
status: 200,
headers: { 'Content-Length': String(padded.length) },
});
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
overflowSiblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
overflowSiblingCancelled,
true,
'sibling stream is cancelled when the other feed exceeds the byte cap',
);
});