From 7af0453f3460bc5b4c48f05df88dfda78ac34a7f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 6 Nov 2026 01:05:50 +0000 Subject: [PATCH] CCTV Street View fallback: catalog cameras only, at their catalog position, inside the Google budget --- server/providers/cctv.js | 33 +++++++++++++++-------------- server/providers/places/google.js | 4 ++-- src/data/cctvMediaFeedType.test.mjs | 27 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/server/providers/cctv.js b/server/providers/cctv.js index a977f4e75..64605b985 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -19,6 +19,8 @@ import { admitSameSiteKeyless } from './common/same-site.js'; import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs'; import { createHlsPuller } from './cctv/stream.js'; import { googleServerApiKey } from './places/google-key.js'; +import { googleRateLimiter } from './places/google.js'; +import { clientKey } from './common/rate-limit.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; /** * Vite plugin: CCTV camera proxy with source registry, frame/media serving, @@ -509,13 +511,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { const source = sourceById.get(cameraId); const label = url.searchParams.get('label') || source?.name || cameraId; const city = url.searchParams.get('city') || source?.city || ''; - const lat = Number(url.searchParams.get('lat') || source?.lat); - const lon = Number(url.searchParams.get('lon') || source?.lon); - const heading = Number( - url.searchParams.get('heading') || source?.headingDeg, - ); - const fov = Number(url.searchParams.get('fov') || source?.fovDeg); - const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg); // Only use server-registered upstream URLs — never accept client-supplied URLs // (prevents SSRF via ?upstream= query parameter) @@ -549,18 +544,24 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { // request (whose client this server cannot see) gets the placeholder, // as if no key were configured. Any forwarding header counts, even an // empty one. + // It is also only for catalog cameras, framed from the catalog (never + // a spot the query names), within the per-IP Google budget. const forwarded = PROXY_SIGNALS.some( (name) => req.headers?.[name] !== undefined, ); - const sv = forwarded - ? null - : await streetViewFallback({ - lat, - lon, - heading, - fov, - pitch, - }); + const googleBudget = googleServerApiKey() ? googleRateLimiter() : null; + const sv = + forwarded || + !source || + (googleBudget && !googleBudget(clientKey(req))) + ? null + : await streetViewFallback({ + lat: Number(source.lat), + lon: Number(source.lon), + heading: Number(source.headingDeg), + fov: Number(source.fovDeg), + pitch: Number(source.pitchDeg ?? -10), + }); if (sv?.ok) { setHealth(cameraId, { status: 'degraded', diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 1ddaa9593..5b034e706 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -27,8 +27,8 @@ let _googleRateLimiter; */ export const GOOGLE_DEFAULT_PER_MIN = 120; -/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */ -function googleRateLimiter() { +/** Google cost endpoints (nearby-places, text-search, geocoding, the CCTV Street View fallback). Null only when set to 0. */ +export function googleRateLimiter() { if (_googleRateLimiter === undefined) _googleRateLimiter = makeCostRateLimiter( process.env.GEV_RATELIMIT_GOOGLE_PER_MIN, diff --git a/src/data/cctvMediaFeedType.test.mjs b/src/data/cctvMediaFeedType.test.mjs index 04dd77e28..0523c95f3 100644 --- a/src/data/cctvMediaFeedType.test.mjs +++ b/src/data/cctvMediaFeedType.test.mjs @@ -528,3 +528,30 @@ test('a forwarded same-origin request reaches every CCTV route but not the Stree assert.equal(direct.body, 'street-view'); assert.equal(streetViewCalls(), 1); }); + +test('the Street View fallback frames catalog cameras only, from the catalog position', async (t) => { + const app = mount(t, ({ url }) => + url.startsWith('https://maps.googleapis.com/') + ? trackedUpstream({ + headers: { 'content-type': 'image/jpeg' }, + body: 'street-view', + }).response + : upstreamResponse({ status: 503 }), + ); + process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key'; + const streetView = () => + app.requests.filter(({ url }) => + url.startsWith('https://maps.googleapis.com/'), + ); + + const unknown = await app.call('/frame/not-a-camera?lat=48.85&lon=2.29'); + assert.equal(unknown.headers['X-CCTV-Source'], 'synthetic'); + assert.deepEqual(streetView(), [], 'no Google call for an unknown id'); + + const steered = await app.call( + `/frame/${STILL.id}?lat=48.85&lon=2.29&heading=90`, + ); + assert.equal(steered.headers['X-CCTV-Source'], 'streetview'); + const location = new URL(streetView()[0].url).searchParams.get('location'); + assert.equal(location, `${STILL.lat},${STILL.lon}`); +});