mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
568 lines
20 KiB
JavaScript
568 lines
20 KiB
JavaScript
import { createCctvCatalog } from './cctv/catalog.js';
|
|
import {
|
|
normalizeFeedType,
|
|
isVideoFeedType,
|
|
toFiniteNumber,
|
|
} from './cctv/normalize.js';
|
|
import {
|
|
buildSyntheticCctvSvg,
|
|
proxyMediaResponse,
|
|
fetchCctvImageFromUpstream,
|
|
fetchTxdotSnapshot,
|
|
fetchCctvMediaUpstream,
|
|
watchDownstreamClose,
|
|
} from './cctv/media.js';
|
|
import {
|
|
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
|
CCTV_MAX_SOURCES_CEILING,
|
|
} from './cctv/constants.js';
|
|
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
|
import { createHlsPuller } from './cctv/stream.js';
|
|
import { googleServerApiKey } from './places/google-key.js';
|
|
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
|
/**
|
|
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
|
|
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
|
|
*
|
|
* Endpoints:
|
|
* GET /api/cctv/sources — list all registered camera sources
|
|
* GET /api/cctv/health — per-camera health/status report
|
|
* GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera
|
|
* GET /api/cctv/media/:id — proxy live video/image media from upstream
|
|
* GET /api/cctv/frame/:id — single frame with fallback chain
|
|
*
|
|
* @returns {import('vite').Plugin}
|
|
*/
|
|
export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|
const getCctvSources = createCctvCatalog({ sourceRoot });
|
|
/** @type {Map<string,{id:string,status:string,sourceKind:string,label:string,message:string,updatedAt:number}>} */
|
|
const health = new Map();
|
|
/** Cap on health map entries to prevent unbounded growth. Sized to the
|
|
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
|
|
* for any catalog the proxy can actually serve. */
|
|
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
|
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
|
const puller = createHlsPuller();
|
|
|
|
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
|
const setHealth = (cameraId, patch) => {
|
|
// Evict oldest entries if the health map grows beyond the cap
|
|
if (!health.has(cameraId) && health.size >= HEALTH_MAX_ENTRIES) {
|
|
const oldest = health.keys().next().value;
|
|
health.delete(oldest);
|
|
}
|
|
const prev = health.get(cameraId) || {};
|
|
health.set(cameraId, {
|
|
id: cameraId,
|
|
status: patch.status || prev.status || 'unknown',
|
|
sourceKind: patch.sourceKind || prev.sourceKind || 'unknown',
|
|
label: patch.label || prev.label || '',
|
|
message: patch.message || prev.message || '',
|
|
updatedAt: Date.now(),
|
|
});
|
|
};
|
|
|
|
/** Snapshot all camera health entries as an array. */
|
|
const listHealth = () => Array.from(health.values());
|
|
|
|
/** Build a JSON payload describing stream info (feedType, URLs) for a camera. */
|
|
const buildStreamPayload = (source, cameraId) => {
|
|
const feedType = normalizeFeedType(source?.feedType || 'image');
|
|
return {
|
|
id: cameraId,
|
|
feedType,
|
|
mediaUrl: isVideoFeedType(feedType)
|
|
? `/api/cctv/media/${encodeURIComponent(cameraId)}`
|
|
: null,
|
|
frameUrl: `/api/cctv/frame/${encodeURIComponent(cameraId)}`,
|
|
provider: source?.provider || '',
|
|
sourceKind:
|
|
source?.sourceKind || (source?.url ? 'configured' : 'fallback'),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Fetch a Google Street View static image as a fallback frame. Server-side
|
|
* call, never reaches the browser — prefers GOOGLE_MAPS_SERVER_API_KEY
|
|
* (#33: a key scoped to Street View Static/Places, restricted by server IP
|
|
* rather than HTTP referrer) and falls back to the browser-exposed
|
|
* GOOGLE_MAPS_API_KEY for setups that haven't split the two yet.
|
|
*/
|
|
const streetViewFallback = async ({ lat, lon, heading, fov, pitch }) => {
|
|
const streetViewKey = googleServerApiKey();
|
|
if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon))
|
|
return null;
|
|
try {
|
|
const sv = new URL('https://maps.googleapis.com/maps/api/streetview');
|
|
sv.searchParams.set('size', '960x540');
|
|
sv.searchParams.set('location', `${lat},${lon}`);
|
|
sv.searchParams.set(
|
|
'heading',
|
|
String(Number.isFinite(heading) ? heading : 0),
|
|
);
|
|
sv.searchParams.set(
|
|
'fov',
|
|
String(Number.isFinite(fov) ? Math.max(20, Math.min(120, fov)) : 80),
|
|
);
|
|
sv.searchParams.set(
|
|
'pitch',
|
|
String(Number.isFinite(pitch) ? Math.max(-40, Math.min(20, pitch)) : 0),
|
|
);
|
|
sv.searchParams.set('source', 'outdoor');
|
|
sv.searchParams.set('return_error_code', 'true');
|
|
sv.searchParams.set('key', streetViewKey);
|
|
|
|
const svResp = await fetch(sv.toString(), {
|
|
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
|
signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS),
|
|
});
|
|
const svType = svResp.headers.get('content-type') || '';
|
|
if (!svResp.ok || !svType.startsWith('image/')) return null;
|
|
|
|
return {
|
|
ok: true,
|
|
body: Buffer.from(await svResp.arrayBuffer()),
|
|
contentType: svType,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const installMiddleware = (server) => {
|
|
server.httpServer?.on('close', () => {
|
|
puller.shutdown();
|
|
});
|
|
server.middlewares.use('/api/cctv', async (req, res) => {
|
|
try {
|
|
const sources = await getCctvSources();
|
|
const sourceById = new Map(
|
|
sources.map((source) => [source.id, source]),
|
|
);
|
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
|
|
if (url.pathname === '/sources') {
|
|
const body = {
|
|
sources: sources.map((source) => ({
|
|
id: source.id,
|
|
name: source.name,
|
|
city: source.city,
|
|
cityId: source.cityId,
|
|
provider: source.provider,
|
|
lat: source.lat,
|
|
lon: source.lon,
|
|
headingDeg: source.headingDeg,
|
|
headingConfidence: source.headingConfidence || '',
|
|
pitchDeg: source.pitchDeg,
|
|
fovDeg: source.fovDeg,
|
|
rangeM: source.rangeM,
|
|
mountHeightM: source.mountHeightM,
|
|
groundElevationM: source.groundElevationM,
|
|
feedType: normalizeFeedType(source.feedType),
|
|
sourceKind:
|
|
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
|
poseSource: source.poseSource,
|
|
license: source.license,
|
|
credit: source.credit || '',
|
|
code: source.code || '',
|
|
groundHeights: source.groundHeights || null,
|
|
})),
|
|
};
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/health') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify({ cameras: listHealth() }));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/stream/')) {
|
|
const cameraId =
|
|
decodeURIComponent(url.pathname.replace('/stream/', '').trim()) ||
|
|
'camera';
|
|
const source = sourceById.get(cameraId);
|
|
const payload = buildStreamPayload(source, cameraId);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify(payload));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/media/')) {
|
|
const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(
|
|
url.pathname,
|
|
);
|
|
if (!match) {
|
|
res.writeHead(404);
|
|
res.end();
|
|
return;
|
|
}
|
|
const cameraId = decodeURIComponent(match[1]);
|
|
const source = sourceById.get(cameraId);
|
|
const mediaUrl = source?.url || '';
|
|
const feedType = normalizeFeedType(source?.feedType || 'image');
|
|
const leaseId = url.searchParams.get('lease');
|
|
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) {
|
|
res.writeHead(400);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (req.method === 'DELETE') {
|
|
if (leaseId) puller.release(cameraId, leaseId);
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (req.method !== 'GET') {
|
|
res.writeHead(405);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (feedType === 'hls') {
|
|
if (
|
|
!/^https?:\/\//i.test(mediaUrl) ||
|
|
!/\.m3u8(?:\?|$)/i.test(mediaUrl)
|
|
) {
|
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
error:
|
|
'This stream requires an unsupported transport; use the frame fallback',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (match[2]) {
|
|
const body = puller.getSegment(
|
|
cameraId,
|
|
url.searchParams.get('session'),
|
|
Number(match[3]),
|
|
leaseId,
|
|
);
|
|
res.writeHead(body ? 200 : 404, {
|
|
'Content-Type': 'video/mp2t',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body || undefined);
|
|
return;
|
|
}
|
|
const downstream = watchDownstreamClose(res);
|
|
let entry;
|
|
const cancelPending = () => {
|
|
if (entry) puller.release(cameraId, leaseId);
|
|
};
|
|
try {
|
|
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
|
if (downstream.closed) {
|
|
cancelPending();
|
|
return;
|
|
}
|
|
downstream.signal.addEventListener('abort', cancelPending, {
|
|
once: true,
|
|
});
|
|
if (!(await puller.waitReady(entry, downstream.signal)))
|
|
throw new Error('Stream unavailable');
|
|
const playlist = await puller.buildPlaylist(
|
|
entry,
|
|
cameraId,
|
|
leaseId,
|
|
);
|
|
if (downstream.closed) return;
|
|
if (!playlist) throw new Error('Stream unavailable');
|
|
setHealth(cameraId, {
|
|
status: 'ok',
|
|
sourceKind: 'live',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Live HLS connected',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'hls-pull',
|
|
'X-CCTV-Session': entry.token,
|
|
});
|
|
res.end(playlist);
|
|
} catch {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'fallback',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Live HLS unavailable',
|
|
});
|
|
if (!downstream.closed) {
|
|
res.writeHead(503, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
'Retry-After': '2',
|
|
});
|
|
res.end(JSON.stringify({ error: 'Live stream unavailable' }));
|
|
}
|
|
} finally {
|
|
downstream.signal.removeEventListener('abort', cancelPending);
|
|
}
|
|
return;
|
|
}
|
|
if (match[2] || req.method !== 'GET') {
|
|
res.writeHead(404);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'fallback',
|
|
label: source?.provider || 'No upstream URL',
|
|
message: 'No stream URL configured',
|
|
});
|
|
res.writeHead(404, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: 'No media URL configured for this camera',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Bound before the request goes out: most of the wait is before any
|
|
// header arrives, and a viewer who leaves during it must take the
|
|
// upstream request with them.
|
|
const downstream = watchDownstreamClose(res);
|
|
try {
|
|
const upstreamHeaders = {
|
|
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
|
};
|
|
// Never forward the client's own string: a Range this proxy does
|
|
// not accept is dropped and the request proceeds without one.
|
|
const requestRange = sanitizeCctvRangeHeader(req.headers?.range);
|
|
if (requestRange) upstreamHeaders.Range = requestRange;
|
|
const upstream = await fetchCctvMediaUpstream(mediaUrl, {
|
|
headers: upstreamHeaders,
|
|
signal: downstream.signal,
|
|
});
|
|
if (downstream.closed) {
|
|
// The headers arrived for a viewer who is no longer there.
|
|
try {
|
|
await upstream.body?.cancel();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
return;
|
|
}
|
|
const contentType = upstream.headers.get('content-type') || '';
|
|
if (!upstream.ok) {
|
|
try {
|
|
await upstream.body?.cancel();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'upstream',
|
|
label: source?.provider || 'Configured source',
|
|
message: `Upstream HTTP ${upstream.status}`,
|
|
});
|
|
res.writeHead(upstream.status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: `Upstream returned ${upstream.status}`,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isVideoFeedType(feedType) &&
|
|
!(
|
|
contentType.startsWith('video/') ||
|
|
contentType.includes('mpegurl')
|
|
)
|
|
) {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'upstream',
|
|
label: source?.provider || 'Configured source',
|
|
message: `Unexpected media type ${contentType || 'unknown'}`,
|
|
});
|
|
} else {
|
|
setHealth(cameraId, {
|
|
status: 'ok',
|
|
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
|
|
label: source?.provider || 'Configured source',
|
|
message: isVideoFeedType(feedType)
|
|
? 'Live stream connected'
|
|
: 'Snapshot feed connected',
|
|
});
|
|
}
|
|
|
|
await proxyMediaResponse(res, upstream, {
|
|
sourceHeader: isVideoFeedType(feedType)
|
|
? 'live-media'
|
|
: 'upstream-image',
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (downstream.closed) {
|
|
// The viewer left mid-request. That is not a camera fault and
|
|
// there is nobody to answer.
|
|
return;
|
|
}
|
|
const timedOut =
|
|
error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
|
// GET /api/cctv/health serializes `message`, and the CCTV panel
|
|
// renders it as a status label, so the raw error would leave the
|
|
// server by a different door than the sanitized body below and
|
|
// land on screen.
|
|
//
|
|
// The log line drops the text too, unlike the catch at the bottom
|
|
// of this file: a media-fetch failure names the camera's upstream
|
|
// host, and these lines get pasted into issues. The status codes
|
|
// below carry the diagnosis — 504 for a timeout, 502 otherwise.
|
|
console.warn('[CCTV Proxy] media fetch failed');
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'upstream',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Media fetch failed',
|
|
});
|
|
res.writeHead(timedOut ? 504 : 502, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: timedOut
|
|
? 'Upstream media timeout'
|
|
: 'Media proxy failed',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!url.pathname.startsWith('/frame/')) {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
return;
|
|
}
|
|
|
|
const cameraId =
|
|
decodeURIComponent(url.pathname.replace('/frame/', '').trim()) ||
|
|
'camera';
|
|
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)
|
|
const upstreamCandidate =
|
|
source?.snapshotUrl ||
|
|
(!isVideoFeedType(normalizeFeedType(source?.feedType))
|
|
? source?.url
|
|
: '');
|
|
|
|
const upstreamImage =
|
|
source?.sourceKind === 'txdot-its'
|
|
? await fetchTxdotSnapshot(upstreamCandidate)
|
|
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
|
if (upstreamImage?.ok) {
|
|
setHealth(cameraId, {
|
|
status: 'ok',
|
|
sourceKind: 'snapshot',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Upstream snapshot active',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': upstreamImage.contentType,
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'upstream-image',
|
|
});
|
|
res.end(upstreamImage.body);
|
|
return;
|
|
}
|
|
|
|
const sv = await streetViewFallback({
|
|
lat,
|
|
lon,
|
|
heading,
|
|
fov,
|
|
pitch,
|
|
});
|
|
if (sv?.ok) {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'streetview',
|
|
label: 'Google Street View',
|
|
message: 'Fallback Street View frame',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': sv.contentType,
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'streetview',
|
|
});
|
|
res.end(sv.body);
|
|
return;
|
|
}
|
|
|
|
const svg = buildSyntheticCctvSvg({
|
|
cameraId,
|
|
label,
|
|
city,
|
|
status: source?.url
|
|
? 'UPSTREAM UNAVAILABLE'
|
|
: 'NO UPSTREAM CONFIGURED',
|
|
});
|
|
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'synthetic',
|
|
label: source?.provider || 'Synthetic fallback',
|
|
message: source?.url
|
|
? 'Upstream unavailable'
|
|
: 'No source configured',
|
|
});
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'image/svg+xml',
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'synthetic',
|
|
});
|
|
res.end(svg);
|
|
} catch (error) {
|
|
console.error('[CCTV Proxy]', error?.message || String(error));
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'CCTV proxy error' }));
|
|
}
|
|
});
|
|
};
|
|
return {
|
|
name: 'cctv-proxy',
|
|
configureServer: installMiddleware,
|
|
configurePreviewServer: installMiddleware,
|
|
};
|
|
}
|