mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
587 lines
21 KiB
JavaScript
587 lines
21 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 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 = {
|
|
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
|
|
sources: sources.map((source) => ({
|
|
id: source.id,
|
|
pack: source.pack,
|
|
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,
|
|
model: source.model || '',
|
|
manufacturer: source.manufacturer || '',
|
|
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');
|
|
// Live feeds only. A still camera is served by /frame, whose fetch
|
|
// keeps redirects on the registered origin, accepts only image/* and
|
|
// caps the body; relaying it here would bypass all three.
|
|
if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') {
|
|
res.writeHead(404, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: 'No live media for this camera; use the frame endpoint',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
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,
|
|
};
|
|
}
|