Compare commits

...

4 Commits

Author SHA1 Message Date
jing
0bbdb30719 feat: Military (ADS-B Exchange) layer — real unfiltered military aircraft
Wires John's paid ADS-B Exchange RapidAPI feed ($10/mo, ~10k req/month) into a
new layer showing the military traffic OpenSky/FR24 hide (C-17 "Reach" airlift,
tankers, the Area 51 "Janet" shuttle, etc.).

- serve.py: proxy/adsbx-mil injects the x-rapidapi-key/host headers SERVER-SIDE
  (key read from gitignored adsbxcredentials.json, never in the browser) and
  HARD-caches /v2/mil/ for 5 min — the quota guard: <=~288 upstream calls/day
  (~8.6k/month) no matter how many tabs poll. Stale-on-error; 503 if unconfigured.
- js/layers/military.js: BillboardCollection like the civilian layer but a
  distinct red; live-only (no history feed), 5-min poll matched to the cache.
  ADSBx altitude is FEET (→metres for Cesium). Emergency squawks (7500/7600/
  7700) highlight brighter+bigger and flip the HUD status to err. Click overlay
  shows type / tail / altitude / speed / squawk.
- config.js: adsbx block + militaryReal/militaryEmerg colours.

Verified in dev: proxy miss→hit (5-min cache, key not leaked), 412 military
aircraft render over CONUS, click RCH042 → C17 / 99-0062 / 34000ft / 424kt,
zero console errors. adsbxcredentials.json stays gitignored + server-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:07:40 +10:00
jing
1e5a241f05 gitignore API credentials (openskycredentials.json + secret patterns)
The OpenSky OAuth2 client credentials live in the repo dir as a JSON file;
gitignore them (and .env/*.secret/*credentials*.json) so they can never be
accidentally committed or shipped. They stay server-side only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:57:29 +10:00
jing
0162ec0cdf pre-deploy: escape external feed strings in InfoBox HTML (ship-check)
Defense-in-depth for the public deploy: USGS/EONET place/title/category are now
HTML-escaped before interpolation into Cesium InfoBox descriptions, and the
USGS/EONET links are passed through lib.safeUrl (http(s)-only) so a hostile feed
can't inject markup or a javascript:/data: href. Entity names/labels stay raw
(Cesium renders those as text). Verified: escapeHtml neutralizes
<img onerror>, safeUrl drops javascript:/data:, 219 quakes + 500 fires still
render normally.

ship-check pass: no auth surface (dev-only history/snap not deployed); no
secrets in tree (AIS key empty, data/ gitignored); proxy upstreams are a fixed
dict (no SSRF); no money paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:55:42 +10:00
jing
b56684c6e6 pre-deploy: pin CDN versions (cesium 1.143.0, satellite.js 5.0.0)
Pin both CDN globals to exact resolved versions so production never drifts if
jsdelivr's @1 / @5 aliases advance (SPEC.md §2.1). Verified the app boots
identically: Cesium.VERSION 1.143.0, satellite.js twoline2satrec present, all
layers + 97 sats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:51:36 +10:00
9 changed files with 231 additions and 11 deletions

5
.gitignore vendored
View File

@ -3,3 +3,8 @@
__pycache__/
# Local recorder database (record.py) — dev-only, never committed or deployed.
data/
# Secrets — API credentials must never be committed or deployed (server-side only).
openskycredentials.json
*credentials*.json
*.secret
.env

View File

@ -4,11 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GODSIGH ▸ World View</title>
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/';</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Widgets/widgets.css" />
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/';</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Widgets/widgets.css" />
<link rel="stylesheet" href="css/style.css" />
<script src="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Cesium.js"></script>
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5/dist/satellite.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Cesium.js"></script>
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5.0.0/dist/satellite.min.js"></script>
</head>
<body>
<div id="cesiumContainer"></div>

View File

@ -88,6 +88,15 @@ export const CONFIG = {
cap: 500,
},
// Military aircraft — ADS-B Exchange /v2/mil/ (REAL unfiltered military, the
// OSINT prize OpenSky hides). Paid RapidAPI feed; the same-origin proxy injects
// the key server-side AND 5-min-caches it, so poll cadence == cache TTL keeps
// the ~10k/month quota safe (~8.6k/month worst case).
adsbx: {
proxyMil: 'proxy/adsbx-mil', // RELATIVE — works at "/" and "/godsigh/"
pollMs: 300_000, // 5 min
},
// Optional live AIS. Leave blank to use the demo fleet only (roadmap feature).
AISSTREAM_API_KEY: '',
@ -108,6 +117,8 @@ export const CONFIG = {
aircraftMid: '#ffe74d',
aircraftHigh: '#4dd2ff',
aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band)
militaryReal: '#ff453a', // REAL military (ADS-B Exchange feed)
militaryEmerg: '#ff2d95', // military squawking 7500/7600/7700 or emergency
// earthquakes (magnitude ramp: amber → red)
quakeMin: '#ffcf50',
quakeMax: '#ff3b30',

View File

@ -59,7 +59,7 @@ export default function create(ctx) {
if (!loc) continue;
const title = ev.title || 'Wildfire';
const category = (ev.categories && ev.categories[0] && ev.categories[0].title) || 'Wildfires';
const link = ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${ev.id}` : null);
const link = lib.safeUrl(ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(ev.id)}` : ''));
const dateTxt = loc.date ? new Date(loc.date).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—';
ds.entities.add({
@ -88,11 +88,11 @@ export default function create(ctx) {
},
description:
`<table class="cesium-infoBox-defaultTable"><tbody>` +
`<tr><th>Event</th><td>${title}</td></tr>` +
`<tr><th>Category</th><td>${category}</td></tr>` +
`<tr><th>Event</th><td>${lib.escapeHtml(title)}</td></tr>` +
`<tr><th>Category</th><td>${lib.escapeHtml(category)}</td></tr>` +
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
`</tbody></table>` +
(link ? `<p><a href="${link}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
(link ? `<p><a href="${lib.escapeHtml(link)}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`,
});
count++;

138
js/layers/military.js Normal file
View File

@ -0,0 +1,138 @@
// Military aircraft (REAL) — ADS-B Exchange /v2/mil/ feed via the paid RapidAPI
// proxy. This is the unfiltered military traffic OpenSky/FR24 hide (C-17 Reach
// flights, tankers, the Area 51 "Janet" shuttle, etc.) — the OSINT prize.
//
// BillboardCollection primitive like the civilian aircraft layer, but a distinct
// red so it never blends in. Live-only (no history feed for this), polled every
// 5 min to match the server-side cache and respect the ~10k/month quota.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
const A = CONFIG.adsbx;
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
const glyph = lib.aircraftGlyph(); // shared triangle, tinted per-billboard
let enabled = true;
let isLive = true;
let inFlight = false;
let timer = null;
let delay = A.pollMs;
ui.addLayer('military', 'Military (ADS-B Exchange)', true, (on) => {
enabled = on;
bc.show = on && isLive;
if (on && isLive) kick();
});
ui.setStatus('military', 'loading…', 'warn');
// Transponder emergency codes → the highlight label.
const EMERG = { '7500': 'HIJACK', '7600': 'RADIO FAIL', '7700': 'EMERGENCY' };
function canPoll() { return enabled && isLive && !document.hidden; }
function schedule(ms) { if (timer) clearTimeout(timer); timer = setTimeout(tick, ms); }
function kick() { if (!canPoll() || timer || inFlight) return; schedule(0); }
async function tick() {
timer = null;
if (!canPoll() || inFlight) return;
inFlight = true;
try { await poll(); } finally { inFlight = false; }
if (canPoll()) schedule(delay);
}
// ADS-B Exchange altitude is in FEET (and "ground" for on-ground aircraft).
function altFeet(a) { return typeof a.alt_baro === 'number' ? a.alt_baro : 0; }
async function poll() {
let res;
try {
res = await fetch(A.proxyMil);
} catch (err) {
ui.setStatus('military', 'network error — retrying', 'warn');
return;
}
if (res.status === 503) {
// Key not configured (e.g. prod before the key is installed) — degrade quietly.
bc.removeAll();
ui.setStatus('military', 'ADS-B Exchange not configured', 'warn');
return;
}
if (!res.ok) {
ui.setStatus('military', `HTTP ${res.status} — retrying`, 'warn');
return;
}
let data;
try {
data = await res.json();
} catch (err) {
ui.setStatus('military', 'bad response — retrying', 'warn');
return;
}
const list = Array.isArray(data && data.ac) ? data.ac : [];
bc.removeAll();
let count = 0;
let emergencies = 0;
for (const a of list) {
if (a.lat == null || a.lon == null) continue;
const sq = a.squawk;
const isEmerg = !!EMERG[sq] || (a.emergency && a.emergency !== 'none');
if (isEmerg) emergencies++;
bc.add({
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048),
image: glyph,
color: lib.cz(isEmerg ? CONFIG.colors.militaryEmerg : CONFIG.colors.militaryReal),
rotation: lib.headingToRotation(a.track), // lib negates — track may be absent (→0)
scale: isEmerg ? 1.05 : 0.82, // bolder than civilian; emergencies bigger still
id: {
layer: 'military',
hex: a.hex,
callsign: (a.flight || '').trim(),
type: a.t,
reg: a.r,
altFt: altFeet(a),
gs: a.gs,
track: a.track,
squawk: sq,
emerg: isEmerg ? (EMERG[sq] || String(a.emergency).toUpperCase()) : null,
},
});
count++;
}
delay = A.pollMs;
const stamp = data.now ? new Date(data.now).toLocaleTimeString() : new Date().toLocaleTimeString();
const em = emergencies ? ` · ${emergencies}` : '';
ui.setStatus('military', `${count} military · ${stamp}${em}`, emergencies ? 'err' : 'ok');
}
function onClockTick(currentTime, live) {
isLive = live;
if (!isLive) { bc.show = false; return; } // live-only feed; pause when scrubbed
bc.show = enabled;
if (enabled) kick();
}
function handlePick(picked) {
if (picked?.id?.layer !== 'military') return false;
const id = picked.id;
const rows = [
['Type', id.type || '—'],
['Reg', id.reg || '—'],
['Altitude', `${Math.round(id.altFt || 0)} ft`],
['Speed', `${Math.round(id.gs || 0)} kt`],
['Squawk', id.squawk || '—'],
];
if (id.emerg) rows.unshift(['⚠ Status', id.emerg]);
ui.showPickOverlay('✈ MIL ' + (id.callsign || id.hex || '—'), rows);
return true;
}
function clearPick() { ui.hidePickOverlay(); }
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
kick();
return { id: 'military', onClockTick, handlePick, clearPick };
}

View File

@ -64,7 +64,8 @@ export default function create(ctx) {
const depth = coords[2];
const mag = p.mag;
const timeMs = p.time;
const place = p.place || 'Unknown location';
const place = p.place || 'Unknown location'; // raw: used in name/label (text contexts)
const url = lib.safeUrl(p.url);
const size = baseSize(mag);
const color = magColor(mag);
const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000;
@ -93,11 +94,11 @@ export default function create(ctx) {
description:
`<table class="cesium-infoBox-defaultTable"><tbody>` +
`<tr><th>Magnitude</th><td>M${mag.toFixed(1)}</td></tr>` +
`<tr><th>Place</th><td>${place}</td></tr>` +
`<tr><th>Place</th><td>${lib.escapeHtml(place)}</td></tr>` +
`<tr><th>Depth</th><td>${depth != null ? depth.toFixed(1) + ' km' : '—'}</td></tr>` +
`<tr><th>Time (UTC)</th><td>${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'}</td></tr>` +
`</tbody></table>` +
(p.url ? `<p><a href="${p.url}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
});
// Labels only for the notable quakes, to keep the globe legible.

View File

@ -77,6 +77,24 @@ export function altitudeColor(CONFIG, altMeters) {
return c.aircraftHigh;
}
// Escape a string for safe interpolation into InfoBox description HTML.
// Feeds like USGS/EONET are trusted, but this is defense-in-depth for the
// public deploy (and the InfoBox is a real HTML sink).
export function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Only allow http(s) URLs into href attributes (blocks javascript:/data: URLs).
export function safeUrl(u) {
const s = String(u == null ? '' : u).trim();
return /^https?:\/\//i.test(s) ? s : '';
}
// CSS hex → Cesium.Color, optional alpha.
export function cz(hex, alpha) {
const col = Cesium.Color.fromCssColorString(hex);

View File

@ -172,6 +172,7 @@ const LAYER_MODULES = [
'./layers/fires.js',
'./layers/ships.js',
'./layers/aircraft.js',
'./layers/military.js',
];
const activeLayers = [];

View File

@ -42,6 +42,22 @@ OPENSKY_CACHE_FRESH_SEC = 120
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
HISTORY_TOLERANCE_SEC = 600
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
# HARD-caches the /v2/mil/ response for 5 min — the quota guard: even with many
# open tabs, at most ~288 upstream calls/day (~8.6k/month) are spent.
ADSBX_CREDS = Path(__file__).resolve().parent / "adsbxcredentials.json"
ADSBX_CACHE_SEC = 300
_adsbx_cache = {"body": None, "ts": 0.0}
def load_adsbx_creds():
try:
d = json.loads(ADSBX_CREDS.read_text())
return d.get("host"), d.get("key")
except (OSError, ValueError):
return None, None
def opensky_cache_file():
override = os.environ.get("OPENSKY_CACHE_FILE")
@ -110,6 +126,8 @@ class Handler(SimpleHTTPRequestHandler):
if not self.path.startswith("/proxy/"):
return super().do_GET()
name, _, query = self.path[len("/proxy/"):].partition("?")
if name == "adsbx-mil":
return self._serve_adsbx_mil()
base = UPSTREAMS.get(name)
if not base:
return self.send_error(404, f"unknown upstream {name!r}")
@ -205,6 +223,34 @@ class Handler(SimpleHTTPRequestHandler):
out.write_bytes(png)
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
def _serve_adsbx_mil(self):
host, key = load_adsbx_creds()
if not host or not key:
# Not configured (e.g. no key file) — the layer degrades gracefully.
return self._send_json(503, {"error": "ADS-B Exchange not configured"})
now = time.time()
if _adsbx_cache["body"] is not None and now - _adsbx_cache["ts"] < ADSBX_CACHE_SEC:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "hit"})
try:
req = urllib.request.Request(
f"https://{host}/v2/mil/",
headers={"x-rapidapi-host": host, "x-rapidapi-key": key,
"User-Agent": "godsigh-dev/0.1"},
)
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
_adsbx_cache["body"] = body
_adsbx_cache["ts"] = now
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if _adsbx_cache["body"] is not None:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(e.code, {"error": f"adsbx upstream {e.code}"})
except Exception as e:
if _adsbx_cache["body"] is not None:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra)