Compare commits

...
5 changed files with 71 additions and 18 deletions

No files matched your search

@@ -0,0 +1,5 @@
- The CCTV Street View fallback serves only catalog cameras, at their catalog
position, heading, field of view and pitch, and ignores coordinates a
request supplies. It spends from the per-client Google budget
(`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120), so the Google key can no
longer be used as an unmetered Street View service (asturksever, #1602).
+4 -1
View File
@@ -1276,7 +1276,10 @@ and follows redirects only on the camera's origin. Catalog image URLs, and
redirects on those hosts, are pinned by parsed URL to the agency's image
directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
request skips the key-bearing Street View fallback and gets the placeholder.
request skips the key-bearing Street View fallback and gets the placeholder. The
Street View fallback serves only catalog cameras, at the catalog position,
heading, field of view and pitch, never coordinates a request supplies, and it
shares the per-client Google budget (`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120).
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
keeps one camera per ground cell sized to the view, preferring a healthy feed,
+3 -1
View File
@@ -377,7 +377,9 @@
"src/data/cctvFootprint.js",
"src/sources/cctvTypes.js",
"src/sources/httpBody.js",
"src/i18n/marker.js"
"src/i18n/marker.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js"
],
"external": []
},
+32 -16
View File
@@ -19,7 +19,22 @@ 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 { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
let _streetViewRateLimiter;
/**
* Per-IP budget for the Street View fallback, on the same setting as the
* other Google routes (GEV_RATELIMIT_GOOGLE_PER_MIN; 0 disables it).
*/
function streetViewRateLimiter() {
if (_streetViewRateLimiter === undefined)
_streetViewRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
120,
);
return _streetViewRateLimiter;
}
/**
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
@@ -509,13 +524,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 +557,26 @@ 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()
? streetViewRateLimiter()
: 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',
+27
View File
@@ -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}`);
});