fix(cctv): make the Lithuania pack opt-in until its licence is confirmed

The camera endpoints are the eismoinfo.lt website backend, which the Via
Lietuva open-data statement does not list, so the pack now follows the NYC
opt-in pattern (CCTV_LITHUANIA_ENABLED=1) and the docs say why. A failed
catalog request now aborts its sibling instead of leaving it open until the
timeout, and a redirect is logged as not followed.
This commit is contained in:
Sameh Khamis committed 2026-10-15 15:17:31 -07:00
1 parent b4d852587a
commit bcfba77196
9 files changed
+125 -36

No files matched your search

+1 -1
View File
@@ -226,7 +226,7 @@ AISSTREAM_API_KEY=
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed # CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) # CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
# CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL # CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL
# CCTV_LITHUANIA_ENABLED=1 # set 0 to disable Via Lietuva / Eismoinfo road cameras # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. - Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. The pack is opt-in (`CCTV_LITHUANIA_ENABLED=1`) because its camera endpoints are the eismoinfo.lt website backend, not a service listed in Via Lietuva's open-data statement, so the licence for the photos is unconfirmed.
- Stop one retired bikeshare feed from reporting the whole layer as failed. - Stop one retired bikeshare feed from reporting the whole layer as failed.
City feeds are fetched independently, but failure was recorded in a single City feeds are fetched independently, but failure was recorded in a single
+1 -1
View File
@@ -91,7 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. The provider's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44) describes reuse of its public data; no specific Creative Commons licence is asserted here. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. Disable with `CCTV_LITHUANIA_ENABLED=0`; `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. - **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
+1 -1
View File
@@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
+4 -2
View File
@@ -981,7 +981,10 @@ round-robin, so adding this pack does not silently drop another region.
## Lithuania CCTV source pack ## Lithuania CCTV source pack
The CCTV catalog includes Via Lietuva / Eismoinfo road cameras by default. The CCTV catalog can include Via Lietuva / Eismoinfo road cameras. The pack is
off unless `CCTV_LITHUANIA_ENABLED=1`, because its camera endpoints are the
eismoinfo.lt website backend rather than a service listed in Via Lietuva's
open-data statement, so the licence for the photos is unconfirmed.
Two keyless public endpoints provide current snapshot URLs and WGS84 camera Two keyless public endpoints provide current snapshot URLs and WGS84 camera
locations; records are joined by ID, with invalid coordinates and off-origin locations; records are joined by ID, with invalid coordinates and off-origin
image URLs rejected. Catalog requests refuse redirects, have a 15-second image URLs rejected. Catalog requests refuse redirects, have a 15-second
@@ -990,7 +993,6 @@ and fair catalog allocation. An unavailable source contributes an empty pack.
Frames use the registered-image proxy and retain Via Lietuva attribution. Frames use the registered-image proxy and retain Via Lietuva attribution.
The images are periodically updated road-condition photographs, not continuous The images are periodically updated road-condition photographs, not continuous
video; camera poses use the existing low-confidence heading prior. video; camera poses use the existing low-confidence heading prior.
`CCTV_LITHUANIA_ENABLED=0` disables the pack, and
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000). `CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
## DriveBC CCTV source pack ## DriveBC CCTV source pack
+10 -5
View File
@@ -26,15 +26,18 @@ import {
/** Env kill switch: unset or anything but "0" means enabled. */ /** Env kill switch: unset or anything but "0" means enabled. */
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
/** Env opt-in: only "1" enables. For packs whose feed access needs an /** Env opt-in: only "1" enables. For packs whose feed access needs an
* operator agreement, so an unset flag never reaches the upstream. */ * operator agreement or whose terms are unconfirmed, so an unset flag never
* reaches the upstream. */
const envOptIn = (name) => String(process.env[name] || '').trim() === '1'; const envOptIn = (name) => String(process.env[name] || '').trim() === '1';
/** /**
* Live open-data packs, in merge order. Adding a region is one entry here * Live open-data packs, in merge order. Adding a region is one entry here
* plus its loader in sources.js; the catalog cap is shared across entries * plus its loader (in sources.js or its own module); the catalog cap is
* round-robin (cap.js), so a new pack never silently evicts an older one. * shared across entries round-robin (cap.js), so a new pack never silently
* evicts an older one.
* Each pack fails independently (allSettled) and is gated by its own env * Each pack fails independently (allSettled) and is gated by its own env
* kill switch, or by an opt-in flag where access needs an agreement. * kill switch, or by an opt-in flag where access needs an agreement or the
* terms are unconfirmed.
*/ */
const LIVE_PACKS = [ const LIVE_PACKS = [
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
@@ -111,8 +114,10 @@ const LIVE_PACKS = [
load: loadNycSourcesFromOpenData, load: loadNycSourcesFromOpenData,
}, },
{ {
// Opt-in until the Via Lietuva open-data terms are confirmed to cover the
// camera photos (see DATA_SOURCES.md).
name: 'lithuania', name: 'lithuania',
enabled: () => envEnabled('CCTV_LITHUANIA_ENABLED'), enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
load: loadLithuaniaSources, load: loadLithuaniaSources,
}, },
]; ];
+32 -13
View File
@@ -81,26 +81,45 @@ export function lithuaniaCameraToSource(row, feature) {
}; };
} }
async function readCatalog(url) { async function readCatalog(url, sibling) {
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); const signal = AbortSignal.any([
const response = await fetch(url, { AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
headers: { Accept: 'application/json' }, sibling.signal,
redirect: 'manual', ]);
signal, try {
}); const response = await fetch(url, {
if (!response.ok) { headers: { Accept: 'application/json' },
await response.body?.cancel(); redirect: 'manual',
throw new Error(`Catalog HTTP ${response.status}`); signal,
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `Catalog HTTP ${response.status}`,
);
}
return await readResponseJsonCapped(
response,
LITHUANIA_MAX_CATALOG_BYTES,
signal,
);
} catch (error) {
// The join needs both catalogs; release the other request at once
// rather than leaving its body open until the timeout.
sibling.abort();
throw error;
} }
return readResponseJsonCapped(response, LITHUANIA_MAX_CATALOG_BYTES, signal);
} }
/** Two bounded requests per shared catalog refresh, no API key required. */ /** Two bounded requests per shared catalog refresh, no API key required. */
export async function loadLithuaniaSources() { export async function loadLithuaniaSources() {
try { try {
const sibling = new AbortController();
const [rows, layers] = await Promise.all([ const [rows, layers] = await Promise.all([
readCatalog(LITHUANIA_CAMERAS_URL), readCatalog(LITHUANIA_CAMERAS_URL, sibling),
readCatalog(LITHUANIA_LOCATIONS_URL), readCatalog(LITHUANIA_LOCATIONS_URL, sibling),
]); ]);
if (!Array.isArray(rows) || !Array.isArray(layers)) return []; if (!Array.isArray(rows) || !Array.isArray(layers)) return [];
const features = layers.find((layer) => layer?.layer === 'VKR')?.features; const features = layers.find((layer) => layer?.layer === 'VKR')?.features;
+71 -11
View File
@@ -95,25 +95,85 @@ test('loader deduplicates and omits cameras without matching published coordinat
); );
}); });
test('catalog includes Lithuania by default and respects the disable switch', async (t) => { /** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
* (undefined leaves it unset), restoring the environment afterwards. */
async function runCatalogWithLithuaniaFlag(t, flag) {
const requested = mockCatalogs(t); const requested = mockCatalogs(t);
const saved = process.env.CCTV_LITHUANIA_ENABLED; const saved = process.env.CCTV_LITHUANIA_ENABLED;
try { try {
delete process.env.CCTV_LITHUANIA_ENABLED; if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
else process.env.CCTV_LITHUANIA_ENABLED = flag;
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
assert.equal( return { requested, sources };
sources.find((c) => c.id === 'lithuania-72')?.cityId,
'lithuania',
);
requested.length = 0;
process.env.CCTV_LITHUANIA_ENABLED = '0';
const disabled = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
assert.ok(!disabled.some((c) => c.cityId === 'lithuania'));
assert.ok(!requested.includes(LITHUANIA_CAMERAS_URL));
} finally { } finally {
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED; if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
else process.env.CCTV_LITHUANIA_ENABLED = saved; else process.env.CCTV_LITHUANIA_ENABLED = saved;
} }
}
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
assert.equal(
sources.find((c) => c.id === 'lithuania-72')?.cityId,
'lithuania',
);
});
// The camera endpoints are the eismoinfo.lt website backend, not a service the
// open-data statement lists, so an install that has not opted in must never
// contact them.
for (const [label, flag] of [
['an unset CCTV_LITHUANIA_ENABLED', undefined],
['an empty CCTV_LITHUANIA_ENABLED', ''],
['CCTV_LITHUANIA_ENABLED=0', '0'],
['CCTV_LITHUANIA_ENABLED=true', 'true'],
]) {
test(`${label} makes no Lithuania request`, async (t) => {
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('eismoinfo.lt')),
false,
'the pack never reaches its upstream',
);
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
});
}
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
t.mock.method(console, 'warn', () => {});
let siblingSignal;
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url === LITHUANIA_CAMERAS_URL) {
return new Response('', { status: 503 });
}
siblingSignal = options.signal;
// Never settles on its own; only an abort ends it.
return new Promise((_, reject) => {
options.signal.addEventListener('abort', () =>
reject(options.signal.reason),
);
});
});
assert.deepEqual(await loadLithuaniaSources(), []);
assert.equal(siblingSignal?.aborted, true);
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
});
test('a redirected catalog is reported as not followed', async (t) => {
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
t.mock.method(
globalThis,
'fetch',
async () =>
new Response('', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
);
assert.deepEqual(await loadLithuaniaSources(), []);
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
}); });
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => { test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
+4 -1
View File
@@ -251,7 +251,10 @@ export const DATA_CREDITS = [
{ {
key: 'lithuania-cctv', key: 'lithuania-cctv',
html: html:
'Road-camera stills (Lithuania): ' + // Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
// among the services Via Lietuva's open-data statement lists (its
// conditions: attribution, reuse on the same terms).
'Road-camera stills (Lithuania, when enabled): ' +
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images', '<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
}, },
{ {