diff --git a/drb-c2-core/app/internal/cameras/base.py b/drb-c2-core/app/internal/cameras/base.py index 5051d2b..9db4a3a 100644 --- a/drb-c2-core/app/internal/cameras/base.py +++ b/drb-c2-core/app/internal/cameras/base.py @@ -147,7 +147,7 @@ class Catalog: self._next_attempt = time.time() + ttl except Exception as e: self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S) - self.error = f"{type(e).__name__}: {e}"[:300] + self.error = safe_error(e) logger.warning(f"cameras/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}") return self @@ -158,6 +158,17 @@ class Catalog: # ── helpers shared by adapters ─────────────────────────────────────────────── +# Query strings are where API keys travel (?key=...). httpx puts the full URL in +# HTTPStatusError text, and this text reaches every signed-in user through the +# status fields of GET /cameras and /traffic/511, so it is dropped here. +_URL_QUERY = re.compile(r"(https?://[^\s'\"?#]+)\?[^\s'\"#]*") + + +def safe_error(e: BaseException) -> str: + """Error text safe to show users: URL query strings removed, length capped.""" + return _URL_QUERY.sub(r"\1?[redacted]", f"{type(e).__name__}: {e}")[:300] + + def make_camera(provider_id: str, raw_id: Any, lat: float, lon: float, *, name: str = "", roadway: str = "", direction: str = "", image_url: Optional[str] = None, video_url: Optional[str] = None, heading_deg: Optional[float] = None) -> Camera: diff --git a/drb-c2-core/app/internal/cameras/general.py b/drb-c2-core/app/internal/cameras/general.py index 3e31f58..3e90d68 100644 --- a/drb-c2-core/app/internal/cameras/general.py +++ b/drb-c2-core/app/internal/cameras/general.py @@ -177,9 +177,12 @@ class CalgaryProvider(CameraProvider): meta = ProviderMeta( id="calgary", name="Open Calgary traffic cameras", region="Calgary, Alberta", coverage=(50.8, -114.5, 51.3, -113.8), + # UNVERIFIED like Ontario 511: the OGL - Calgary grant is per GEV, not read + # first-hand (camera-sources.md: "Yes, attribution (per GEV)"), so it is gated. licence=LicenceInfo( - status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (OGL - City of Calgary)", - terms="Open Government Licence - City of Calgary: reuse permitted with attribution.", + status=Licence.UNVERIFIED, + label="Licence UNVERIFIED -- OGL - City of Calgary per GEV, terms page not read first-hand", + terms="Open Government Licence - City of Calgary (reuse with attribution) per GEV; not confirmed first-hand.", terms_url="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa", attribution="Contains information licensed under the Open Government Licence - City of Calgary", attribution_url="https://data.calgary.ca"), @@ -215,9 +218,11 @@ class TflProvider(CameraProvider): meta = ProviderMeta( id="tfl", name="TfL JamCams", region="London, UK", coverage=(51.2, -0.7, 51.8, 0.4), + # UNVERIFIED: the TfL terms page 403'd during the survey and the grant is + # per GEV only (camera-sources.md: "re-read"), so it is gated until re-read. licence=LicenceInfo( - status=Licence.ATTRIBUTION_REQUIRED, - label="Commercial OK with attribution (\"Powered by TfL Open Data\") -- TfL terms page not re-read", + status=Licence.UNVERIFIED, + label="Licence UNVERIFIED -- commercial OK with attribution per GEV; TfL terms page not read first-hand", terms="TfL transport data terms: free incl. commercial, with attribution (per GEV; the terms page " "returned 403 during the survey -- re-read before selling).", terms_url="https://tfl.gov.uk/info-for/open-data-users/our-open-data", diff --git a/drb-c2-core/app/internal/ny511.py b/drb-c2-core/app/internal/ny511.py index 65e37ce..6a940ca 100644 --- a/drb-c2-core/app/internal/ny511.py +++ b/drb-c2-core/app/internal/ny511.py @@ -38,6 +38,7 @@ import httpx from app.config import settings from app.internal.logger import logger +from app.internal.cameras.base import safe_error LEGACY_BASE = "https://511ny.org/api" CAMERAS_TTL_S = 60 * 60 # camera list is near-static @@ -73,7 +74,7 @@ class _Feed: self._next_attempt = time.time() + self.ttl_s except Exception as e: self._next_attempt = time.time() + min(self.ttl_s, RETRY_AFTER_FAILURE_S) - self.error = f"{type(e).__name__}: {e}"[:300] + self.error = safe_error(e) # never echo ?key= to viewers logger.warning(f"511NY {self.path} refresh failed, serving {len(self.items)} cached: {self.error}") return self diff --git a/drb-c2-core/tests/test_camera_providers.py b/drb-c2-core/tests/test_camera_providers.py index 5d5e5a8..dfa91f8 100644 --- a/drb-c2-core/tests/test_camera_providers.py +++ b/drb-c2-core/tests/test_camera_providers.py @@ -111,6 +111,18 @@ def test_gated_licence_requires_acknowledgement(store): assert store.audit[-1]["details"]["provider_id"] == "ny511" +def test_licences_not_read_first_hand_are_gated_but_keep_attribution(): + # TfL (terms page 403'd) and Calgary (per GEV only) are not confirmed, so an + # admin must acknowledge before they serve; their credit still renders. + for pid in ("tfl", "calgary", "ontario511"): + lic = registry.PROVIDERS[pid].meta.licence + assert lic.status == Licence.UNVERIFIED and lic.gated, pid + assert registry.PROVIDERS["tfl"].meta.licence.attribution == "Powered by TfL Open Data" + with pytest.raises(registry.ProviderError) as e: + run(registry.set_enabled("tfl", True, ("u", "e"))) + assert e.value.extra["requires_acknowledgement"] is True + + def test_gated_enable_refused_when_audit_cannot_be_written(store): with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))): with pytest.raises(registry.ProviderError) as e: @@ -141,7 +153,7 @@ def test_unreadable_settings_fail_closed(): def test_query_returns_attribution_and_skips_providers_outside_bbox(store): run(registry.set_enabled("nsw", True, ("u", "e"))) - run(registry.set_enabled("tfl", True, ("u", "e"))) + run(registry.set_enabled("tfl", True, ("u", "e"), acknowledge_licence=True)) cam = base.make_camera("nsw", "x", -33.87, 151.2, name="Sydney", image_url="https://webcams.transport.nsw.gov.au/a.jpg") nsw_fetch, tfl_fetch = AsyncMock(return_value=[cam]), AsyncMock(return_value=[]) with patch.object(registry.PROVIDERS["nsw"], "fetch_catalog", nsw_fetch), \ @@ -277,6 +289,18 @@ def test_ny511_provider_wraps_legacy_client_and_carries_daa_metadata(): assert "with all faults" in lic.disclaimer and lic.gated +def test_refresh_error_never_echoes_an_api_key(): + import httpx + req = httpx.Request("GET", "https://511on.ca/api/v2/get/cameras", params={"format": "json", "key": "SECRET123"}) + with pytest.raises(httpx.HTTPStatusError) as raised: # httpx's own message carries the full URL + httpx.Response(403, request=req).raise_for_status() + err = raised.value + cat = registry.CATALOGS["ontario511"] + with patch.object(cat.provider, "fetch_catalog", AsyncMock(side_effect=err)): + run(cat.get()) + assert "SECRET123" not in cat.error and "403" in cat.error and "511on.ca/api/v2/get/cameras" in cat.error + + def test_failed_refresh_keeps_last_good_catalog(): cat = registry.CATALOGS["nsw"] cam = base.make_camera("nsw", "x", -33.8, 151.2) diff --git a/drb-frontend/lib/useCameras.ts b/drb-frontend/lib/useCameras.ts index 9605ab1..8c267dc 100644 --- a/drb-frontend/lib/useCameras.ts +++ b/drb-frontend/lib/useCameras.ts @@ -60,8 +60,11 @@ export function useMapCameras(map: L.Map, enabled: boolean) { return useCameraFetch( enabled ? () => { + // Clamp: zoomed out (or panned past the antimeridian) Leaflet bounds run + // past ±180/±90, and GET /cameras answers 422 to an out-of-range bbox. const b = map.getBounds().pad(BBOX_PAD); - return { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }; + const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v)); + return { south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180) }; } : null, [map, enabled],