mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into feature/geo-uri-search
This commit is contained in:
commit
0c3723e930
12 files changed
+348
-17
No files matched your search
@@ -3,6 +3,12 @@ import {
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
} from './sourcePolicy.js';
|
||||
const FRAME_SOURCE_KINDS = new Set([
|
||||
'upstream-image',
|
||||
'hls-pull',
|
||||
'streetview',
|
||||
'synthetic',
|
||||
]);
|
||||
function safeNumber(value, fallback = NaN) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
@@ -51,7 +57,7 @@ export function createCctvSource({
|
||||
},
|
||||
getFrameUrl: frameUrlFor,
|
||||
getMediaUrl: mediaUrlFor,
|
||||
/** Read one current frame through the registered frame endpoint. */
|
||||
/** Read a registered frame and its provenance, including fallback frames. */
|
||||
async getFrame(camera, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(frameUrlFor(camera), {
|
||||
@@ -62,7 +68,12 @@ export function createCctvSource({
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
const type = response.headers.get('content-type') || '';
|
||||
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
|
||||
const sourceKind = response.headers.get('x-cctv-source');
|
||||
return {
|
||||
contentType: type.split(';')[0].trim().toLowerCase(),
|
||||
bytes,
|
||||
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
|
||||
});
|
||||
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
|
||||
});
|
||||
|
||||
test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => {
|
||||
for (const kind of [
|
||||
'upstream-image',
|
||||
'hls-pull',
|
||||
'streetview',
|
||||
'synthetic',
|
||||
'other',
|
||||
null,
|
||||
]) {
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async () =>
|
||||
new Response(new Uint8Array([1]), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
...(kind ? { 'X-CCTV-Source': kind } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const frame = await source.getFrame(camera);
|
||||
assert.equal(frame.sourceKind, kind === 'other' ? null : kind);
|
||||
}
|
||||
});
|
||||
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
|
||||
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
|
||||
function install(plugin) {
|
||||
function install(plugin, hook = 'configureServer') {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
@@ -17,9 +18,16 @@ function install(plugin) {
|
||||
},
|
||||
});
|
||||
assert.equal(routes.size, 1);
|
||||
return async (url = '/', method = 'GET') => {
|
||||
return async (url = '/', method = 'GET', headers = {}) => {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
headers: {},
|
||||
set statusCode(status) {
|
||||
this.status = status;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers, headersSent: true });
|
||||
},
|
||||
@@ -27,7 +35,7 @@ function install(plugin) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await [...routes.values()][0]({ url, method }, res);
|
||||
await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('traffic refuses cross-site requests in dev and preview before spending the TomTom budget', async (t) => {
|
||||
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' });
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
const host = 'localhost:5173';
|
||||
const refused = [
|
||||
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://evil.example' }, // fetch from another page
|
||||
{ host, origin: 'null' }, // sandboxed frame
|
||||
{ host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy
|
||||
];
|
||||
for (const [mode, hook] of [
|
||||
['dev', 'configureServer'],
|
||||
['preview', 'configurePreviewServer'],
|
||||
]) {
|
||||
const request = install(tomtomProxy(), hook);
|
||||
// Each refused shape asks for distinct uncached tiles: without the gate
|
||||
// every one would be a billed attempt counted against the daily budget.
|
||||
let x = 0;
|
||||
for (const headers of refused)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers);
|
||||
assert.equal(res.status, 403, `${mode}: ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.equal(
|
||||
(await request('/status', 'GET', refused[0])).status,
|
||||
403,
|
||||
`${mode}: status is gated too`,
|
||||
);
|
||||
assert.equal(
|
||||
calls,
|
||||
mode === 'dev' ? 0 : 2,
|
||||
`${mode}: no refused upstream work`,
|
||||
);
|
||||
const app = { host, 'sec-fetch-site': 'same-origin' };
|
||||
assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0);
|
||||
// The app itself and loopback tools (no browser headers) still get tiles.
|
||||
const tile = await request('/flow/8/1/1.pbf', 'GET', app);
|
||||
assert.equal(tile.status, 200);
|
||||
assert.equal(tile.headers['x-tomtom-cache'], 'MISS');
|
||||
assert.equal((await request('/flow/8/2/1.pbf')).status, 200);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/flow/8/1/1.pbf', 'GET', {
|
||||
host,
|
||||
origin: `http://${host}`,
|
||||
})
|
||||
).headers['x-tomtom-cache'],
|
||||
'HIT',
|
||||
);
|
||||
assert.equal(calls, mode === 'dev' ? 2 : 4);
|
||||
}
|
||||
});
|
||||
|
||||
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
|
||||
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
|
||||
const bodies = [];
|
||||
|
||||
@@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({
|
||||
|
||||
export const getCctvSnapshot = defineTool({
|
||||
name: 'get_cctv_snapshot',
|
||||
title: 'Current camera view',
|
||||
title: 'Camera snapshot',
|
||||
description:
|
||||
'The current image from one public camera, by the id from find_cctv_cameras.',
|
||||
'An image from one public camera, by the id from find_cctv_cameras. ' +
|
||||
'Street View fallback imagery is historical, not the current camera view; ' +
|
||||
'unknown frame provenance is explicit.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
|
||||
@@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({
|
||||
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
|
||||
);
|
||||
const frame = await services.cctv.getFrame(camera, { signal });
|
||||
if (!FRAME_TYPES.has(frame.contentType))
|
||||
if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
`Camera ${camera.name || camera.id} has no still image right now`,
|
||||
@@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({
|
||||
'The camera image is too large to return',
|
||||
);
|
||||
const name = text(camera.name) || camera.id;
|
||||
const fallback = frame.sourceKind === 'streetview';
|
||||
const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind);
|
||||
const credit = fallback ? 'Google Street View' : text(camera.credit);
|
||||
const label = fallback
|
||||
? 'Street View fallback for'
|
||||
: current
|
||||
? 'Current view from'
|
||||
: 'Image from';
|
||||
return {
|
||||
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
|
||||
summary:
|
||||
`${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` +
|
||||
(fallback
|
||||
? ' Historical imagery; the current camera frame is unavailable.'
|
||||
: current
|
||||
? ''
|
||||
: ' Frame provenance is unknown.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
point: { lat: camera.lat, lon: camera.lon },
|
||||
@@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({
|
||||
id: camera.id,
|
||||
name,
|
||||
city: text(camera.city),
|
||||
provider: text(camera.provider),
|
||||
credit: text(camera.credit),
|
||||
provider: fallback ? 'Google Street View' : text(camera.provider),
|
||||
credit,
|
||||
source_kind: frame.sourceKind ?? null,
|
||||
current_view: current ? true : fallback ? false : null,
|
||||
mime_type: frame.contentType,
|
||||
bytes: frame.bytes.byteLength,
|
||||
},
|
||||
|
||||
@@ -30,7 +30,13 @@ const cameras = [
|
||||
{ id: 'no-position', name: 'Unplaced' },
|
||||
];
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
|
||||
const cctv = (
|
||||
frame = {
|
||||
contentType: 'image/png',
|
||||
bytes: PNG,
|
||||
sourceKind: 'upstream-image',
|
||||
},
|
||||
) => ({
|
||||
frames: [],
|
||||
async getCatalog() {
|
||||
return { sources: cameras };
|
||||
@@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('mounted camera fallback provenance survives the source and MCP image result', async (t) => {
|
||||
const { cctvProxy } = await import('../../../server/providers/cctv.js');
|
||||
const { createCctvSource } = await import('../../layers/cctv/source.js');
|
||||
const camera = {
|
||||
id: 'fixture-camera',
|
||||
name: 'Fixture crossing',
|
||||
city: 'Fixture City',
|
||||
provider: 'Fixture transport agency',
|
||||
credit: 'Fixture transport agency',
|
||||
lat: 30.2685,
|
||||
lon: -97.7425,
|
||||
headingDeg: 180,
|
||||
fovDeg: 60,
|
||||
url: 'https://camera.example/current.png',
|
||||
feedType: 'image',
|
||||
};
|
||||
const environment = {
|
||||
CCTV_SOURCES_JSON: JSON.stringify([camera]),
|
||||
CCTV_PREFER_AUSTIN: '0',
|
||||
CCTV_FORCE_AUSTIN: '0',
|
||||
CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key',
|
||||
GOOGLE_MAPS_API_KEY: '',
|
||||
};
|
||||
for (const [name, value] of Object.entries(environment)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
process.env[name] = value;
|
||||
}
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let upstreamAvailable = false;
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (raw) => {
|
||||
const url = new URL(raw);
|
||||
calls.push(url.origin);
|
||||
if (url.origin === 'https://camera.example')
|
||||
return upstreamAvailable
|
||||
? new Response(PNG, { headers: { 'Content-Type': 'image/png' } })
|
||||
: new Response(null, { status: 503 });
|
||||
assert.equal(
|
||||
url.origin,
|
||||
'https://maps.googleapis.com',
|
||||
'no actual upstream is contacted',
|
||||
);
|
||||
assert.equal(url.searchParams.get('key'), 'fixture-key');
|
||||
return new Response(PNG, { headers: { 'Content-Type': 'image/png' } });
|
||||
});
|
||||
let middleware;
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
assert.equal(route, '/api/cctv');
|
||||
middleware = handler;
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async (path) => {
|
||||
assert.ok(path.startsWith('/api/cctv/'));
|
||||
const response = {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await middleware(
|
||||
{ url: path.slice('/api/cctv'.length), method: 'GET', headers: {} },
|
||||
response,
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: source },
|
||||
});
|
||||
const fallback = await catalog.call('get_cctv_snapshot', {
|
||||
camera_id: camera.id,
|
||||
});
|
||||
assert.equal(fallback.data.source_kind, 'streetview');
|
||||
assert.equal(fallback.data.current_view, false);
|
||||
assert.equal(fallback.data.credit, 'Google Street View');
|
||||
assert.match(fallback.summary, /Street View fallback/);
|
||||
assert.match(fallback.summary, /Historical imagery/);
|
||||
assert.doesNotMatch(
|
||||
fallback.summary,
|
||||
/Current view|courtesy of Fixture transport agency/,
|
||||
);
|
||||
assert.equal(
|
||||
fallback.images.length,
|
||||
1,
|
||||
'the historical image stays available with its caveat',
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'https://camera.example',
|
||||
'https://maps.googleapis.com',
|
||||
]);
|
||||
upstreamAvailable = true;
|
||||
const current = await catalog.call('get_cctv_snapshot', {
|
||||
camera_id: camera.id,
|
||||
});
|
||||
assert.equal(current.data.source_kind, 'upstream-image');
|
||||
assert.equal(current.data.current_view, true);
|
||||
assert.equal(current.data.credit, camera.credit);
|
||||
assert.match(current.summary, /^Current view from/);
|
||||
const server = createMcpServer({ catalog, name: 'test', version: '1' });
|
||||
upstreamAvailable = false;
|
||||
const result = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
|
||||
});
|
||||
assert.ok(
|
||||
result.result.content.some(
|
||||
(entry) => entry.type === 'text' && /Historical imagery/.test(entry.text),
|
||||
),
|
||||
);
|
||||
assert.ok(result.result.content.some((entry) => entry.type === 'image'));
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = '';
|
||||
const placeholder = await source.getFrame(camera);
|
||||
assert.equal(placeholder.sourceKind, 'synthetic');
|
||||
assert.equal(placeholder.contentType, 'image/svg+xml');
|
||||
await assert.rejects(
|
||||
catalog.call('get_cctv_snapshot', { camera_id: camera.id }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
const unavailable = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
|
||||
});
|
||||
assert.equal(unavailable.result.isError, true);
|
||||
assert.ok(
|
||||
!unavailable.result.content.some((entry) => entry.type === 'image'),
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => {
|
||||
const synthetic = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
cctv: cctv({
|
||||
contentType: 'image/png',
|
||||
bytes: PNG,
|
||||
sourceKind: 'synthetic',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
const unknown = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) },
|
||||
}).call('get_cctv_snapshot', { camera_id: 'austin-1' });
|
||||
assert.equal(unknown.data.current_view, null);
|
||||
assert.equal(
|
||||
unknown.data.credit,
|
||||
cameras[0].credit,
|
||||
'catalog attribution is preserved',
|
||||
);
|
||||
assert.match(unknown.summary, /provenance is unknown/);
|
||||
assert.doesNotMatch(unknown.summary, /Current view/);
|
||||
});
|
||||
|
||||
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
|
||||
const catalog = (frame) =>
|
||||
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });
|
||||
|
||||
Reference in new issue
Block a user