mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 12:58:11 +02:00
19fb7f0b1e
Reported by @tg12 in the external security/correctness audit.
Before this change, /api/live-data/{fast,slow} accepted s/w/n/e query
params but their Query() descriptions explicitly said "(ignored)". The
endpoints shipped the full in-memory world dataset on every poll:
/api/live-data/fast → 16.88 MB
/api/live-data/slow → 10.12 MB
── 27 MB per poll cycle, regardless of zoom
For a node with N operators each polling at the steady 15s/120s cadence,
this is hundreds of MB/minute of outbound traffic that never gets used —
the GPU just culls everything outside the viewport client-side. On a
Tor-bridged or LTE-backed node, that bandwidth bill is the actual cost.
This change makes the existing s/w/n/e params honored — when all four
bounds are supplied, the backend bbox-filters a curated set of heavy,
density-driven, time-sensitive collections to that viewport (with the
existing 20% padding from _bbox_filter):
/fast: commercial_flights, military_flights, private_flights,
private_jets, tracked_flights, ships, cctv, uavs, liveuamap,
gps_jamming, sigint, trains
/slow: gdelt, firms_fires, kiwisdr, scanners, psk_reporter
Static reference layers (satellites, datacenters, military_bases,
power_plants, satnogs, weather, news, stocks, etc.) deliberately STAY
world-scale so panning never reveals an "empty world" of infrastructure.
That preserves the no-hostile-UX feel of the existing dashboard.
Behavior contract:
* Without bbox params (or with a partial bbox), the response is
byte-for-byte identical to the pre-#288 implementation. No
behavior change for any existing caller that hasn't opted in.
* World-scale bbox (lng_span >= 300 or lat_span >= 120) short-circuits
filtering and shares the global ETag — zoomed-out operators all
hit the same 304 cache exactly like before.
* ETag now mixes a 1°-quantized bbox suffix when filtering engages,
so two viewports never poison each other's 304 cache. Sub-degree
pans land in the same ETag bucket (i.e. don't bust the cache on
every mouse drag).
Polling cadence, rate-limit windows, and the 304 short-circuit are all
unchanged. Only the SIZE of the responses changes, and only when the
caller opts in via bounds.
Frontend wiring: useViewportBounds reuses the same coarsened/
expanded bounds it already computes for the AIS /api/viewport POST and
pushes them into a new module-level liveDataViewport store.
useDataPolling reads from that store via appendLiveDataBoundsParams
when building each live-data URL.
Tests cover: no-bbox → world data; bbox → heavy layers filtered;
bbox → reference layers untouched; world-scale bbox → no filter;
partial bbox → treated as no bbox; ETag changes with bbox; sub-degree
pan → same ETag; 304 path works; antimeridian-crossing bbox handled.
Co-authored-by: BigBodyCobain <moatbc@gmail.com>
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import { useCallback, useRef, useState } from 'react';
|
|
import type { RefObject } from 'react';
|
|
import type { MapRef } from 'react-map-gl/maplibre';
|
|
import { API_BASE } from '@/lib/api';
|
|
import {
|
|
coarsenViewBounds,
|
|
expandBoundsToRadius,
|
|
normalizeViewBounds,
|
|
type ViewBounds,
|
|
} from '@/lib/viewportPrivacy';
|
|
import { setLiveDataBounds } from '@/lib/liveDataViewport';
|
|
|
|
const VIEWPORT_POST_DEBOUNCE_MS = 2500;
|
|
const VIEWPORT_POST_MIN_INTERVAL_MS = 12000;
|
|
const VIEWPORT_CHANGE_EPSILON = 1.5;
|
|
export const VIEWPORT_COMMITTED_EVENT = 'shadowbroker:viewport-committed';
|
|
|
|
function boundsChanged(a: ViewBounds | null, b: ViewBounds): boolean {
|
|
if (!a) return true;
|
|
return (
|
|
Math.abs(a.south - b.south) > VIEWPORT_CHANGE_EPSILON ||
|
|
Math.abs(a.west - b.west) > VIEWPORT_CHANGE_EPSILON ||
|
|
Math.abs(a.north - b.north) > VIEWPORT_CHANGE_EPSILON ||
|
|
Math.abs(a.east - b.east) > VIEWPORT_CHANGE_EPSILON
|
|
);
|
|
}
|
|
|
|
export function useViewportBounds(
|
|
mapRef: RefObject<MapRef | null>,
|
|
viewBoundsRef?: { current: ViewBounds | null },
|
|
backendViewportSyncEnabled: boolean = true,
|
|
) {
|
|
// Viewport bounds for culling off-screen features [west, south, east, north]
|
|
const [mapBounds, setMapBounds] = useState<[number, number, number, number]>([
|
|
-180, -90, 180, 90,
|
|
]);
|
|
|
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastPostedBoundsRef = useRef<ViewBounds | null>(null);
|
|
const lastPostedAtRef = useRef(0);
|
|
const lastCommittedBoundsRef = useRef<ViewBounds | null>(null);
|
|
|
|
const updateBounds = useCallback(() => {
|
|
const map = mapRef.current?.getMap();
|
|
if (!map) return;
|
|
const b = map.getBounds();
|
|
const latRange = b.getNorth() - b.getSouth();
|
|
const lngRange = b.getEast() - b.getWest();
|
|
const buf = 0.2; // 20% buffer
|
|
setMapBounds([
|
|
b.getWest() - lngRange * buf,
|
|
b.getSouth() - latRange * buf,
|
|
b.getEast() + lngRange * buf,
|
|
b.getNorth() + latRange * buf,
|
|
]);
|
|
|
|
const normalized = normalizeViewBounds({
|
|
south: b.getSouth(),
|
|
west: b.getWest(),
|
|
north: b.getNorth(),
|
|
east: b.getEast(),
|
|
});
|
|
const preloadBounds = coarsenViewBounds(expandBoundsToRadius(normalized));
|
|
|
|
if (viewBoundsRef && 'current' in viewBoundsRef) {
|
|
viewBoundsRef.current = preloadBounds;
|
|
}
|
|
|
|
if (boundsChanged(lastCommittedBoundsRef.current, preloadBounds)) {
|
|
lastCommittedBoundsRef.current = preloadBounds;
|
|
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
|
|
}
|
|
|
|
// Issue #288: hand the same coarsened/expanded bounds to the live-data
|
|
// poller so heavy collections in /api/live-data/{fast,slow} can be
|
|
// scoped to the visible region. Static reference layers are unaffected
|
|
// — see backend _FAST_BBOX_HEAVY_KEYS / _SLOW_BBOX_HEAVY_KEYS.
|
|
setLiveDataBounds({
|
|
south: preloadBounds.south,
|
|
west: preloadBounds.west,
|
|
north: preloadBounds.north,
|
|
east: preloadBounds.east,
|
|
});
|
|
|
|
// Debounce POSTing viewport bounds to backend for dynamic AIS stream filtering
|
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
if (!backendViewportSyncEnabled) {
|
|
lastPostedBoundsRef.current = null;
|
|
lastPostedAtRef.current = 0;
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (
|
|
!boundsChanged(lastPostedBoundsRef.current, preloadBounds) &&
|
|
now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS
|
|
) {
|
|
return;
|
|
}
|
|
if (now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS) {
|
|
return;
|
|
}
|
|
lastPostedBoundsRef.current = preloadBounds;
|
|
lastPostedAtRef.current = now;
|
|
fetch(`${API_BASE}/api/viewport`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
s: preloadBounds.south,
|
|
w: preloadBounds.west,
|
|
n: preloadBounds.north,
|
|
e: preloadBounds.east,
|
|
}),
|
|
}).catch((e) => console.error('Failed to update backend viewport:', e));
|
|
}, VIEWPORT_POST_DEBOUNCE_MS);
|
|
}, [backendViewportSyncEnabled, mapRef, viewBoundsRef]);
|
|
|
|
const inView = useCallback(
|
|
(lat: number, lng: number) =>
|
|
lng >= mapBounds[0] && lng <= mapBounds[2] && lat >= mapBounds[1] && lat <= mapBounds[3],
|
|
[mapBounds],
|
|
);
|
|
|
|
const scheduleBoundsUpdate = useCallback(() => {
|
|
updateBounds();
|
|
}, [updateBounds]);
|
|
|
|
return { mapBounds, inView, updateBounds, scheduleBoundsUpdate };
|
|
}
|