mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(cctv): give Catalonia cameras stable ids and pin their frame paths
Ids were built from the feed's fid, which embeds an upstream server-start stamp, so every upstream restart renamed all cameras (it already had since the PR fixture). Ids now come from the validated frame URL (cat-sct-<stem>, or host and path for partner cameras), which also collapses the five duplicate rows. Frame links must sit directly in each host's frame directory with no explicit port, and sctidcam must be a bare image filename. Numeric entity decoding handles hex references and leaves invalid code points as written, so one bad name no longer empties the pack. Counts in the docs are now ~130.
This commit is contained in:
1 parent
e8b6ffd196
commit
3f239f1173
8 files changed
+333
-32
No files matched your search
@@ -251,14 +251,19 @@ export const CATALONIA_CAMERAS_URL =
|
||||
/** Byte ceiling for the Catalonia catalog read (~90 KB observed 2026-09-27),
|
||||
* so a runaway body cannot be buffered without limit. */
|
||||
export const CATALONIA_MAX_CATALOG_BYTES = 1024 * 1024;
|
||||
/** Allowed image hosts, one per kept `font` publisher in the feed. */
|
||||
/** Allowed image hosts, one per kept `font` publisher in the feed, each
|
||||
* mapped to the one directory its frame links live in (every link in the
|
||||
* 2026-10 feed shares it); a link must name a single file directly inside. */
|
||||
export const CATALONIA_IMAGE_HOSTS = Object.freeze(
|
||||
new Set([
|
||||
'mct.gencat.cat', // SCT
|
||||
'www.bcn.cat', // IMI (Ajuntament de Barcelona)
|
||||
'emap.terrassa.cat', // Ajuntament de Terrassa
|
||||
new Map([
|
||||
['mct.gencat.cat', '/mct2bo/'], // SCT
|
||||
['www.bcn.cat', '/transit/imatges/'], // IMI (Ajuntament de Barcelona)
|
||||
['emap.terrassa.cat', '/it_terrassa/'], // Ajuntament de Terrassa
|
||||
]),
|
||||
);
|
||||
/** SCT `sctidcam` values: a bare image filename, which also keys the camera
|
||||
* id (the feed's `fid` embeds an upstream server-start stamp). */
|
||||
export const CATALONIA_SCT_CAMERA_FILE_RE = /^[a-z0-9]+\.(gif|jpe?g)$/i;
|
||||
/** Per-`font` partner credit, shown beside the provider in the CCTV panel
|
||||
* (same pattern as DriveBC's partner-supplied cameras). SCT is the feed's
|
||||
* own publisher and carries no separate credit. */
|
||||
|
||||
@@ -406,16 +406,24 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode decimal numeric HTML character references ("í" -> "í"), the
|
||||
* entity form the Catalonia feed uses for accented place names, plus the
|
||||
* handful of named entities XML/HTML text commonly carries.
|
||||
* Decode numeric HTML character references, decimal ("í") and hex
|
||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||
* place names, plus the handful of named entities XML/HTML text commonly
|
||||
* carries. A reference that names no valid character (NUL, a surrogate, or
|
||||
* anything past U+10FFFF) is left as written rather than thrown on, so one
|
||||
* bad field cannot fail a whole catalog.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decodeNumericEntities(text) {
|
||||
return String(text || '')
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#(?:x([0-9a-f]+)|(\d+));/gi, (match, hex, dec) => {
|
||||
const code = hex ? Number.parseInt(hex, 16) : Number(dec);
|
||||
const valid =
|
||||
code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff);
|
||||
return valid ? String.fromCodePoint(code) : match;
|
||||
})
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
CATALONIA_IMAGE_HOSTS,
|
||||
CATALONIA_SCT_CAMERA_FILE_RE,
|
||||
CATALONIA_FONT_CREDIT,
|
||||
CATALONIA_FONT_ELEVATION_M,
|
||||
DEFAULT_CATALONIA_MAX_SOURCES,
|
||||
@@ -1668,10 +1669,11 @@ export function parseCataloniaXml(xml) {
|
||||
/**
|
||||
* Validate and normalize one Catalonia feed `link` against the publisher
|
||||
* host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects
|
||||
* anything else, including a host that merely resembles an allowed one, and
|
||||
* any URL carrying userinfo (`https://user:[email protected]/...`): the
|
||||
* hostname check alone would pass it, letting catalog data inject
|
||||
* credentials into the server-side frame request.
|
||||
* anything else, including a host that merely resembles an allowed one, any
|
||||
* URL carrying userinfo (`https://user:[email protected]/...`) or an explicit
|
||||
* port — the hostname check alone would pass either, letting catalog data
|
||||
* inject credentials or steer the server-side frame request to another
|
||||
* service on that host — and any path outside the host's frame directory.
|
||||
*
|
||||
* SCT's own `RenderService` frame link is a special case, for two stacked
|
||||
* reasons (both verified 2026-09-15):
|
||||
@@ -1688,7 +1690,8 @@ export function parseCataloniaXml(xml) {
|
||||
* TransitCamera answers the frame directly over **http**, no redirect, and
|
||||
* that is the only transport this host actually serves to this runtime — so
|
||||
* this builds that URL itself from RenderService's `sctidcam` id instead of
|
||||
* registering the (https, then-redirecting) RenderService link.
|
||||
* registering the (https, then-redirecting) RenderService link. The
|
||||
* `sctidcam` id must be a bare image filename (CATALONIA_SCT_CAMERA_FILE_RE).
|
||||
*
|
||||
* @param {string} rawUrl
|
||||
* @returns {string} Normalized URL, or '' if not accepted.
|
||||
@@ -1701,12 +1704,16 @@ export function normalizeCataloniaImageUrl(rawUrl) {
|
||||
return '';
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
||||
if (parsed.username || parsed.password) return '';
|
||||
if (!CATALONIA_IMAGE_HOSTS.has(parsed.hostname)) return '';
|
||||
if (parsed.username || parsed.password || parsed.port) return '';
|
||||
const dir = CATALONIA_IMAGE_HOSTS.get(parsed.hostname);
|
||||
if (!dir || !parsed.pathname.startsWith(dir)) return '';
|
||||
const file = parsed.pathname.slice(dir.length);
|
||||
if (!file || file.includes('/')) return '';
|
||||
|
||||
if (parsed.hostname === 'mct.gencat.cat') {
|
||||
const camId = parsed.searchParams.get('sctidcam');
|
||||
if (!camId) return '';
|
||||
if (file !== 'RenderService') return '';
|
||||
if (!camId || !CATALONIA_SCT_CAMERA_FILE_RE.test(camId)) return '';
|
||||
const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera');
|
||||
direct.searchParams.set('nom', camId);
|
||||
direct.searchParams.set('visualitzacio', 'imatge');
|
||||
@@ -1717,6 +1724,30 @@ export function normalizeCataloniaImageUrl(rawUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable camera id for one normalized Catalonia frame URL (the output of
|
||||
* normalizeCataloniaImageUrl). The feed's `fid` is not used: it embeds an
|
||||
* upstream server-start stamp and changes on every upstream restart. SCT
|
||||
* cameras key on the `sctidcam` filename stem (`cat-sct-nc87`), partner
|
||||
* cameras on the frame host and path (`cat-www-bcn-cat-transit-imatges-…`).
|
||||
*
|
||||
* @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cataloniaCameraId(imageUrl) {
|
||||
const parsed = new URL(imageUrl);
|
||||
const slug = (text) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (parsed.hostname === 'mct.gencat.cat') {
|
||||
const file = parsed.searchParams.get('nom') || '';
|
||||
return `cat-sct-${slug(file.replace(/\.[^.]*$/, ''))}`;
|
||||
}
|
||||
return `cat-${slug(parsed.hostname)}-${slug(parsed.pathname)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for one Catalonia camera: "<road/place> (<municipality>)",
|
||||
* skipping the parenthetical when the municipality is already named in the
|
||||
@@ -1751,6 +1782,10 @@ export function cataloniaCameraName(carretera, municipi) {
|
||||
* shared `provider`. The feed carries no heading, so every camera takes the
|
||||
* id-hash fallback, low-confidence pose (same as TfL and Fintraffic).
|
||||
*
|
||||
* Ids come from the validated frame URL (cataloniaCameraId), not the feed's
|
||||
* restart-dependent `fid`, and rows sharing one are collapsed before the cap
|
||||
* (the live feed repeats a few SCT cameras).
|
||||
*
|
||||
* The list decides ids, coordinates, labels and which allowlisted frame path
|
||||
* is proxied, so it is fetched over https, redirects are refused (the list
|
||||
* host cannot be steered or downgraded) and the body read is capped at
|
||||
@@ -1791,15 +1826,12 @@ export async function loadCataloniaSourcesFromOpenData() {
|
||||
|
||||
const cameras = [];
|
||||
for (const row of rows) {
|
||||
if (!row.fid) continue;
|
||||
if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue;
|
||||
const imageUrl = normalizeCataloniaImageUrl(row.link);
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const font = row.font.trim();
|
||||
const cameraId = `cat-${row.fid
|
||||
.replace(/^cameres\.fid-/, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
const cameraId = cataloniaCameraId(imageUrl);
|
||||
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
|
||||
Reference in new issue
Block a user