mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f90913fb | ||
|
|
42ce346ba2 |
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).
|
|
||||||
@@ -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).
|
||||||
@@ -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
|
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. The
|
request skips the key-bearing Street View fallback and gets the placeholder.
|
||||||
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,
|
||||||
|
|||||||
@@ -377,9 +377,7 @@
|
|||||||
"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": []
|
||||||
},
|
},
|
||||||
@@ -5083,7 +5081,8 @@
|
|||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/infrastructureData.js",
|
"src/sources/infrastructureData.js",
|
||||||
"src/sources/eventPacks.js"
|
"src/sources/eventPacks.js",
|
||||||
|
"src/keylessGeocoder.js"
|
||||||
],
|
],
|
||||||
"external": ["@mapbox/vector-tile", "pbf"]
|
"external": ["@mapbox/vector-tile", "pbf"]
|
||||||
},
|
},
|
||||||
|
|||||||
+16
-32
@@ -19,22 +19,7 @@ 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.
|
||||||
@@ -524,6 +509,13 @@ 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)
|
||||||
@@ -557,26 +549,18 @@ 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 googleBudget = googleServerApiKey()
|
const sv = forwarded
|
||||||
? streetViewRateLimiter()
|
? null
|
||||||
: null;
|
: await streetViewFallback({
|
||||||
const sv =
|
lat,
|
||||||
forwarded ||
|
lon,
|
||||||
!source ||
|
heading,
|
||||||
(googleBudget && !googleBudget(clientKey(req)))
|
fov,
|
||||||
? null
|
pitch,
|
||||||
: 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',
|
||||||
|
|||||||
@@ -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(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}`);
|
|
||||||
});
|
|
||||||
+19
-1
@@ -24,6 +24,7 @@ export function createGeocodePlaceService({
|
|||||||
cacheMs = 10 * 60_000,
|
cacheMs = 10 * 60_000,
|
||||||
timeoutMs = 15_000,
|
timeoutMs = 15_000,
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
|
keylessGeocoder = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
// Recent answers by name, so tools that combine others resolve a place to
|
// 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
|
// 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) {
|
async function lookup(name, signal) {
|
||||||
const query = new URLSearchParams({ q: name });
|
const query = new URLSearchParams({ q: name });
|
||||||
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
|
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}`);
|
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
|
||||||
const payload = await response.json();
|
|
||||||
return placeFromGeocodeResult(payload?.results?.[0]);
|
return placeFromGeocodeResult(payload?.results?.[0]);
|
||||||
}
|
}
|
||||||
function start(key, name) {
|
function start(key, name) {
|
||||||
@@ -100,6 +110,14 @@ export function createGeocodePlaceService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Convert one geocode result to a place with bounds, or null. */
|
/** 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) {
|
export function placeFromGeocodeResult(result) {
|
||||||
const location = result?.geometry?.location;
|
const location = result?.geometry?.location;
|
||||||
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
||||||
|
|||||||
@@ -93,3 +93,49 @@ test('a shared lookup outlives one cancelled caller, and times out on its own',
|
|||||||
await places.resolve('Slow').catch(() => {});
|
await places.resolve('Slow').catch(() => {});
|
||||||
assert.equal(signals.length, 3, 'a timed-out lookup is not cached');
|
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']);
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
createVesselSource,
|
createVesselSource,
|
||||||
createFlightSource,
|
createFlightSource,
|
||||||
} from '../sources/live/standalone.js';
|
} from '../sources/live/standalone.js';
|
||||||
|
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||||
import {
|
import {
|
||||||
createGeocodePlaceService,
|
createGeocodePlaceService,
|
||||||
createPlaceSearchService,
|
createPlaceSearchService,
|
||||||
@@ -86,7 +87,10 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) {
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
tileFetchImpl: fetchImpl,
|
tileFetchImpl: fetchImpl,
|
||||||
}),
|
}),
|
||||||
places: createGeocodePlaceService({ fetchImpl }),
|
places: createGeocodePlaceService({
|
||||||
|
fetchImpl,
|
||||||
|
keylessGeocoder: createPhotonGeocoder({ fetchImpl }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user