Compare commits

..

No commits in common. "0bbdb30719b7d71521f8b556200ce3943132af22" and "bf4d1d16dea792cdf8ed3c49fd9ad4adaad1e9f3" have entirely different histories.

9 changed files with 11 additions and 231 deletions

5
.gitignore vendored
View File

@ -3,8 +3,3 @@
__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.143.0/Build/Cesium/';</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Widgets/widgets.css" />
<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" />
<link rel="stylesheet" href="css/style.css" />
<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>
<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>
</head>
<body>
<div id="cesiumContainer"></div>

View File

@ -88,15 +88,6 @@ 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: '',
@ -117,8 +108,6 @@ 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 = lib.safeUrl(ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(ev.id)}` : ''));
const link = ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${ev.id}` : null);
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>${lib.escapeHtml(title)}</td></tr>` +
`<tr><th>Category</th><td>${lib.escapeHtml(category)}</td></tr>` +
`<tr><th>Event</th><td>${title}</td></tr>` +
`<tr><th>Category</th><td>${category}</td></tr>` +
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
`</tbody></table>` +
(link ? `<p><a href="${lib.escapeHtml(link)}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
(link ? `<p><a href="${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++;

View File

@ -1,138 +0,0 @@
// 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,8 +64,7 @@ export default function create(ctx) {
const depth = coords[2];
const mag = p.mag;
const timeMs = p.time;
const place = p.place || 'Unknown location'; // raw: used in name/label (text contexts)
const url = lib.safeUrl(p.url);
const place = p.place || 'Unknown location';
const size = baseSize(mag);
const color = magColor(mag);
const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000;
@ -94,11 +93,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>${lib.escapeHtml(place)}</td></tr>` +
`<tr><th>Place</th><td>${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>` +
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
(p.url ? `<p><a href="${p.url}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
});
// Labels only for the notable quakes, to keep the globe legible.

View File

@ -77,24 +77,6 @@ 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,7 +172,6 @@ const LAYER_MODULES = [
'./layers/fires.js',
'./layers/ships.js',
'./layers/aircraft.js',
'./layers/military.js',
];
const activeLayers = [];

View File

@ -42,22 +42,6 @@ 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")
@ -126,8 +110,6 @@ 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}")
@ -223,34 +205,6 @@ 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)