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 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 directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded 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 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, 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/data/cctvFootprint.js",
"src/sources/cctvTypes.js", "src/sources/cctvTypes.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js" "src/i18n/marker.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js"
], ],
"external": [] "external": []
}, },
+32 -16
View File
@@ -19,7 +19,22 @@ import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs'; import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js'; import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js'; import { googleServerApiKey } from './places/google-key.js';
import { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; 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, * Vite plugin: CCTV camera proxy with source registry, frame/media serving,
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking. * 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 source = sourceById.get(cameraId);
const label = url.searchParams.get('label') || source?.name || cameraId; const label = url.searchParams.get('label') || source?.name || cameraId;
const city = url.searchParams.get('city') || source?.city || ''; 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 // Only use server-registered upstream URLs — never accept client-supplied URLs
// (prevents SSRF via ?upstream= query parameter) // (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, // request (whose client this server cannot see) gets the placeholder,
// as if no key were configured. Any forwarding header counts, even an // as if no key were configured. Any forwarding header counts, even an
// empty one. // 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( const forwarded = PROXY_SIGNALS.some(
(name) => req.headers?.[name] !== undefined, (name) => req.headers?.[name] !== undefined,
); );
const sv = forwarded const googleBudget = googleServerApiKey()
? null ? streetViewRateLimiter()
: await streetViewFallback({ : null;
lat, const sv =
lon, forwarded ||
heading, !source ||
fov, (googleBudget && !googleBudget(clientKey(req)))
pitch, ? 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) { if (sv?.ok) {
setHealth(cameraId, { setHealth(cameraId, {
status: 'degraded', 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(direct.body, 'street-view');
assert.equal(streetViewCalls(), 1); 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}`);
});