Compare commits

..
9 changed files with 93 additions and 74 deletions

No files matched your search

@@ -1,5 +0,0 @@
- 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).
+3
View File
@@ -0,0 +1,3 @@
- MCP tools resolve place names through the keyless Photon geocoder when the
server has no geocoder configured, instead of answering "No place matched"
for every name (asturksever, #1603).
+1 -4
View File
@@ -1276,10 +1276,7 @@ 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. 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).
request skips the key-bearing Street View fallback and gets the placeholder.
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 -4
View File
@@ -377,9 +377,7 @@
"src/data/cctvFootprint.js",
"src/sources/cctvTypes.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js"
"src/i18n/marker.js"
],
"external": []
},
@@ -5083,7 +5081,8 @@
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js",
"src/sources/eventPacks.js"
"src/sources/eventPacks.js",
"src/keylessGeocoder.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
+16 -32
View File
@@ -19,22 +19,7 @@ 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.
@@ -524,6 +509,13 @@ 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)
@@ -557,26 +549,18 @@ 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 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),
});
const sv = forwarded
? null
: await streetViewFallback({
lat,
lon,
heading,
fov,
pitch,
});
if (sv?.ok) {
setHealth(cameraId, {
status: 'degraded',
-27
View File
@@ -528,30 +528,3 @@ 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}`);
});
+19 -1
View File
@@ -24,6 +24,7 @@ export function createGeocodePlaceService({
cacheMs = 10 * 60_000,
timeoutMs = 15_000,
now = () => Date.now(),
keylessGeocoder = null,
} = {}) {
// Recent answers by name, so tools that combine others resolve a place to
// the same point once instead of once per section. A pending lookup is
@@ -33,8 +34,17 @@ export function createGeocodePlaceService({
async function lookup(name, signal) {
const query = new URLSearchParams({ q: name });
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
const payload = await response.json().catch(() => null);
// No server geocoder (no Google key, no Nominatim): ask the keyless one
// the search box uses, rather than reporting every name as unmatched.
if (
keylessGeocoder &&
(payload?.configured === false || payload?.code === 'NOMINATIM_DISABLED')
) {
const { place } = await keylessGeocoder.geocode(name, { signal });
return place ? placeFromKeylessResult(place) : null;
}
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
const payload = await response.json();
return placeFromGeocodeResult(payload?.results?.[0]);
}
function start(key, name) {
@@ -100,6 +110,14 @@ export function createGeocodePlaceService({
}
/** Convert one geocode result to a place with bounds, or null. */
/** A keyless (Photon) answer in the same shape as a geocode result's. */
function placeFromKeylessResult({ lat, lng, label, name, viewport }) {
return placeFromGeocodeResult({
formatted_address: label || name || null,
geometry: { location: { lat, lng }, ...(viewport ? { viewport } : {}) },
});
}
export function placeFromGeocodeResult(result) {
const location = result?.geometry?.location;
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
+46
View File
@@ -93,3 +93,49 @@ test('a shared lookup outlives one cancelled caller, and times out on its own',
await places.resolve('Slow').catch(() => {});
assert.equal(signals.length, 3, 'a timed-out lookup is not cached');
});
test('without a server geocoder, names resolve through the keyless geocoder', async () => {
const asked = [];
const places = createGeocodePlaceService({
fetchImpl: async () =>
Response.json(
{
error: 'Place search is not configured',
code: 'NOMINATIM_DISABLED',
retryable: false,
},
{ status: 503 },
),
keylessGeocoder: {
geocode: async (name) => {
asked.push(name);
return name === 'Paris'
? {
place: {
lat: 48.8566,
lng: 2.3522,
name: 'Paris',
label: 'Paris, France',
viewport: {
southwest: { lat: 48.81, lng: 2.22 },
northeast: { lat: 48.9, lng: 2.47 },
},
},
answered: true,
}
: { place: null, answered: true };
},
},
});
const paris = await places.resolve('Paris');
assert.equal(paris.name, 'Paris, France');
assert.deepEqual(paris.point, { lat: 48.8566, lon: 2.3522 });
assert.deepEqual(paris.bounds, {
west: 2.22,
south: 48.81,
east: 2.47,
north: 48.9,
});
assert.equal(await places.resolve('Nowhere-at-all'), null);
assert.deepEqual(asked, ['Paris', 'Nowhere-at-all']);
});
+5 -1
View File
@@ -35,6 +35,7 @@ import {
createVesselSource,
createFlightSource,
} from '../sources/live/standalone.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js';
import {
createGeocodePlaceService,
createPlaceSearchService,
@@ -86,7 +87,10 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) {
fetchImpl,
tileFetchImpl: fetchImpl,
}),
places: createGeocodePlaceService({ fetchImpl }),
places: createGeocodePlaceService({
fetchImpl,
keylessGeocoder: createPhotonGeocoder({ fetchImpl }),
}),
};
}