mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(cctv): harden Tarktee DATEX catalog fetches and dedupe before cap
This commit is contained in:
1 parent
93ea088f45
commit
3792d7377e
3 files changed
+194
-8
No files matched your search
@@ -188,6 +188,11 @@ export const TARKTEE_IMAGES_URL =
|
|||||||
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
||||||
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
||||||
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
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 = [
|
export const TARKTEE_ANCHORS = [
|
||||||
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
||||||
{ lat: 58.378, lon: 26.729 }, // Tartu
|
{ lat: 58.378, lon: 26.729 }, // Tartu
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
TARKTEE_IMAGES_URL,
|
TARKTEE_IMAGES_URL,
|
||||||
TARKTEE_IMAGE_ORIGIN,
|
TARKTEE_IMAGE_ORIGIN,
|
||||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||||
|
TARKTEE_MAX_CATALOG_BYTES,
|
||||||
|
TARKTEE_FETCH_TIMEOUT_MS,
|
||||||
TARKTEE_ANCHORS,
|
TARKTEE_ANCHORS,
|
||||||
DEFAULT_WARENDORF_SOURCE_FILE,
|
DEFAULT_WARENDORF_SOURCE_FILE,
|
||||||
WARENDORF_IMAGE_ORIGINS,
|
WARENDORF_IMAGE_ORIGINS,
|
||||||
@@ -1194,25 +1196,62 @@ export async function loadTarkteeSourcesFromDatex() {
|
|||||||
const [locResp, imgResp] = await Promise.all([
|
const [locResp, imgResp] = await Promise.all([
|
||||||
fetch(TARKTEE_LOCATIONS_URL, {
|
fetch(TARKTEE_LOCATIONS_URL, {
|
||||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
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, {
|
fetch(TARKTEE_IMAGES_URL, {
|
||||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
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) {
|
if (!locResp.ok) {
|
||||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||||
return [];
|
return discardBoth();
|
||||||
}
|
}
|
||||||
if (!imgResp.ok) {
|
if (!imgResp.ok) {
|
||||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
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 locations = parseTarkteeDatexLocations(locXml);
|
||||||
const images = parseTarkteeDatexImages(imgXml);
|
const images = parseTarkteeDatexImages(imgXml);
|
||||||
if (!locations.size || !images.size) {
|
if (!locations.size || !images.size) {
|
||||||
@@ -1224,6 +1263,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cameras = [];
|
const cameras = [];
|
||||||
|
const seen = new Set();
|
||||||
for (const [locationId, loc] of locations.entries()) {
|
for (const [locationId, loc] of locations.entries()) {
|
||||||
const imageUrl = images.get(locationId);
|
const imageUrl = images.get(locationId);
|
||||||
if (!imageUrl) continue;
|
if (!imageUrl) continue;
|
||||||
@@ -1235,6 +1275,8 @@ export async function loadTarkteeSourcesFromDatex() {
|
|||||||
const cameraId = numMatch
|
const cameraId = numMatch
|
||||||
? `ee-tarktee-${numMatch[1]}`
|
? `ee-tarktee-${numMatch[1]}`
|
||||||
: `ee-tarktee-${locationId}`;
|
: `ee-tarktee-${locationId}`;
|
||||||
|
if (seen.has(cameraId)) continue;
|
||||||
|
seen.add(cameraId);
|
||||||
cameras.push({
|
cameras.push({
|
||||||
id: cameraId,
|
id: cameraId,
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
TARKTEE_IMAGES_URL,
|
||||||
|
TARKTEE_LOCATIONS_URL,
|
||||||
|
TARKTEE_MAX_CATALOG_BYTES,
|
||||||
|
} from '../../server/providers/cctv/constants.js';
|
||||||
import {
|
import {
|
||||||
loadTallinnSourcesFromCatalog,
|
loadTallinnSourcesFromCatalog,
|
||||||
|
loadTarkteeSourcesFromDatex,
|
||||||
parseTarkteeDatexImages,
|
parseTarkteeDatexImages,
|
||||||
parseTarkteeDatexLocations,
|
parseTarkteeDatexLocations,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} 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"?>
|
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||||
<payloadPublication>
|
<payloadPublication>
|
||||||
@@ -84,3 +96,130 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
|||||||
const images = parseTarkteeDatexImages(xml);
|
const images = parseTarkteeDatexImages(xml);
|
||||||
assert.equal(images.size, 0);
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user