Ran a 7-reviewer × per-finding-verifier workflow over the Wave 2 code; 9 of 13 raw findings survived adversarial verification. Fixes: - quakes.js: refresh now UPDATES revised quakes, not just dedupes — a signature (mag|place|depth|time) per id detects USGS revisions and rebuilds that entity, so a quake revised M4.4→M5.2 no longer stays visually understated (SPEC2 §2 "update, don't duplicate"). - aircraft.js: (a) poll() drops its result if the clock scrubbed off-live mid- fetch, so late live data never paints over the replay view; (b) re-enabling the aircraft layer while scrubbed now restores the replayed frame instead of leaving it hidden; (c) transient (non-404) history errors schedule a bounded retry so a paused clock doesn't wedge on an error frame. - satellites.js: ground track centers on the VIEWER clock (not wall-clock now) so it stays under a scrubbed satellite; and it hides when the Satellites layer is toggled off (no stray dashed line). - main.js: malformed camera hash with blank tokens (#c=,,,,) is now rejected instead of applying a bogus (0,0,0) camera — falls back to the default view. - serve.py: history t-parse catches OverflowError (t=inf/1e999 → 400, not a crashed handler); stale-on-error now also covers upstream 5xx, not just 429. Verified: t=inf/-inf/1e999/nan → 400; malformed hash → default Gulf camera; ground track 427 km from the scrubbed dot (was ~2 orbits off) and hidden on layer-off; aircraft re-enable during replay restores 6062 billboards; zero console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
11 KiB
Python
252 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""GODSIGH dev server: static files + same-origin proxy for feeds that block CORS.
|
|
|
|
Also implements the **OpenSky shared cache v1** contract (see SPEC2.md §4), which
|
|
lets GODSIGH and its sibling project godstrument poll OpenSky from the same IP
|
|
without racing each other into the anonymous daily quota. The contract is
|
|
mirrored verbatim in godstrument's brief — keep the two in sync.
|
|
"""
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zlib
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
UPSTREAMS = {
|
|
# OpenSky pins Access-Control-Allow-Origin to its own domain, so the browser
|
|
# can't call it directly — we forward it here instead.
|
|
"opensky": "https://opensky-network.org/api/states/all",
|
|
# Celestrak sends ACAO:* today; proxied too so the app keeps working if that changes.
|
|
"celestrak": "https://celestrak.org/NORAD/elements/gp.php",
|
|
}
|
|
|
|
# ---- OpenSky shared cache v1 ------------------------------------------------
|
|
# Path: ~/.cache/godverse/opensky-states.json (overridable via OPENSKY_CACHE_FILE).
|
|
# Content: the raw, unmodified /states/all JSON body (global, no bbox). Freshness
|
|
# = file mtime; readers treat <120 s as fresh. On upstream 429/failure a reader
|
|
# may serve a stale cache rather than nothing.
|
|
OPENSKY_CACHE_FRESH_SEC = 120
|
|
|
|
# ---- Historical replay (record.py writes this db; dev-only) -----------------
|
|
# GET history/aircraft?t=<epoch_s> returns the nearest recorded snapshot within
|
|
# ±10 min, or 404. Prod has no recorder → the file is absent → graceful 404.
|
|
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
|
|
HISTORY_TOLERANCE_SEC = 600
|
|
|
|
|
|
def opensky_cache_file():
|
|
override = os.environ.get("OPENSKY_CACHE_FILE")
|
|
return Path(override if override else "~/.cache/godverse/opensky-states.json").expanduser()
|
|
|
|
|
|
def has_bbox(query):
|
|
# A global states/all request has no bounding-box params; only those bypass the cache.
|
|
q = query.lower()
|
|
return any(k in q for k in ("lamin", "lomin", "lamax", "lomax"))
|
|
|
|
|
|
def read_cache_if_fresh(path, max_age):
|
|
try:
|
|
st = path.stat()
|
|
except OSError:
|
|
return None
|
|
if time.time() - st.st_mtime >= max_age:
|
|
return None
|
|
try:
|
|
return path.read_bytes()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def read_cache_any(path):
|
|
try:
|
|
return path.read_bytes()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def write_cache(path, body):
|
|
# Atomic write: a unique temp file *beside* the target (same filesystem, so
|
|
# os.replace is atomic) then os.replace over the target. A unique temp name
|
|
# means concurrent writers (GODSIGH + godstrument, or two threads here) never
|
|
# corrupt each other's cache or expose a half-written file. Best-effort —
|
|
# caching must never break the actual response.
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(body)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def end_headers(self):
|
|
# Dev server: never let the browser cache our static assets, so edits to
|
|
# js/css always take effect on reload (module graphs cache aggressively).
|
|
self.send_header("Cache-Control", "no-store")
|
|
super().end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/history/"):
|
|
return self._serve_history()
|
|
if not self.path.startswith("/proxy/"):
|
|
return super().do_GET()
|
|
name, _, query = self.path[len("/proxy/"):].partition("?")
|
|
base = UPSTREAMS.get(name)
|
|
if not base:
|
|
return self.send_error(404, f"unknown upstream {name!r}")
|
|
|
|
# Shared cache applies only to the OpenSky global states feed.
|
|
opensky_global = name == "opensky" and not has_bbox(query)
|
|
cache = opensky_cache_file()
|
|
|
|
# 1) Serve-from-cache: fresh (<120 s) global request costs zero quota.
|
|
if opensky_global:
|
|
fresh = read_cache_if_fresh(cache, OPENSKY_CACHE_FRESH_SEC)
|
|
if fresh is not None:
|
|
return self._send(200, "application/json", fresh, {"X-Godsigh-Cache": "hit"})
|
|
|
|
url = base + ("?" + query if query else "")
|
|
passthrough = {}
|
|
|
|
def grab(hdrs):
|
|
for k in ("X-Rate-Limit-Remaining", "X-Rate-Limit-Limit", "X-Expires-After"):
|
|
if hdrs.get(k) is not None:
|
|
passthrough[k] = hdrs.get(k)
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "godsigh-dev/0.1"})
|
|
with urllib.request.urlopen(req, timeout=45) as r:
|
|
body = r.read()
|
|
status, ctype = r.status, r.headers.get("Content-Type", "text/plain")
|
|
grab(r.headers)
|
|
# 2) Write-through: a healthy global fetch refreshes the shared cache.
|
|
if opensky_global and status == 200:
|
|
write_cache(cache, body)
|
|
passthrough["X-Godsigh-Cache"] = "miss"
|
|
except urllib.error.HTTPError as e:
|
|
# 3) Stale-on-error: on 429 (quota) or any 5xx (upstream failure), keep
|
|
# the client working from the last-known cache. 4xx client errors are
|
|
# NOT masked with stale data.
|
|
if opensky_global and (e.code == 429 or e.code >= 500):
|
|
stale = read_cache_any(cache)
|
|
if stale is not None:
|
|
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
|
|
body = e.read() or str(e).encode()
|
|
status, ctype = e.code, "text/plain"
|
|
grab(e.headers)
|
|
except Exception as e:
|
|
# Same stale fallback for connection errors/timeouts.
|
|
if opensky_global:
|
|
stale = read_cache_any(cache)
|
|
if stale is not None:
|
|
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
|
|
body, status, ctype = str(e).encode(), 502, "text/plain"
|
|
|
|
self._send(status, ctype, body, passthrough)
|
|
|
|
def _send(self, status, ctype, body, extra=None):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header(
|
|
"Access-Control-Expose-Headers",
|
|
"X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After, X-Godsigh-Cache",
|
|
)
|
|
for k, v in (extra or {}).items():
|
|
self.send_header(k, v)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_POST(self):
|
|
# Dev-only screenshot sink: POST snap?name=<name> with a base64 PNG (or a
|
|
# full data:image/png;base64,... URL) body → writes docs/<name>.png. The
|
|
# server binds 127.0.0.1 only, and the name is sanitized to [a-z0-9-] so
|
|
# the write can never escape docs/. serve.py is never deployed.
|
|
if not (self.path == "/snap" or self.path.startswith("/snap?")):
|
|
return self.send_error(404, "not found")
|
|
_, _, query = self.path.partition("?")
|
|
raw_name = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
|
|
name = re.sub(r"[^a-z0-9-]", "", raw_name.lower()) or "screenshot"
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
text = self.rfile.read(length).decode("utf-8", "replace") if length else ""
|
|
if text.strip().startswith("data:") and "," in text:
|
|
text = text.split(",", 1)[1]
|
|
try:
|
|
png = base64.b64decode(text, validate=False)
|
|
except Exception:
|
|
return self._send_json(400, {"error": "body must be base64 PNG (optionally a data: URL)"})
|
|
if not png:
|
|
return self._send_json(400, {"error": "empty image"})
|
|
docs = (Path(__file__).resolve().parent / "docs")
|
|
docs.mkdir(parents=True, exist_ok=True)
|
|
out = (docs / f"{name}.png").resolve()
|
|
if out.parent != docs.resolve():
|
|
return self._send_json(400, {"error": "invalid name"})
|
|
out.write_bytes(png)
|
|
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
|
|
|
|
def _send_json(self, status, obj, extra=None):
|
|
self._send(status, "application/json", json.dumps(obj).encode(), extra)
|
|
|
|
def _serve_history(self):
|
|
# /history/aircraft?t=<epoch_s> → nearest snapshot within ±10 min, or 404.
|
|
kind, _, query = self.path[len("/history/"):].partition("?")
|
|
params = urllib.parse.parse_qs(query)
|
|
t_raw = (params.get("t") or [None])[0]
|
|
if kind != "aircraft" or t_raw is None:
|
|
return self._send_json(400, {"error": "usage: history/aircraft?t=<epoch_s>"})
|
|
try:
|
|
t = int(float(t_raw))
|
|
except (ValueError, OverflowError): # OverflowError: t=inf / 1e999
|
|
return self._send_json(400, {"error": "t must be epoch seconds"})
|
|
if not HISTORY_DB.exists():
|
|
return self._send_json(404, {"error": "no history for this time"})
|
|
try:
|
|
# Read-only, short-lived connection per request — WAL lets us read
|
|
# without ever locking out the recorder's writes.
|
|
con = sqlite3.connect(f"file:{HISTORY_DB}?mode=ro", uri=True, timeout=2)
|
|
try:
|
|
row = con.execute(
|
|
"SELECT ts, body FROM snapshots WHERE kind='aircraft' "
|
|
"ORDER BY ABS(ts - ?) ASC LIMIT 1", (t,)).fetchone()
|
|
finally:
|
|
con.close()
|
|
except sqlite3.Error as e:
|
|
return self._send_json(500, {"error": f"history db error: {e}"})
|
|
if not row or abs(row[0] - t) > HISTORY_TOLERANCE_SEC:
|
|
return self._send_json(404, {"error": "no history for this time"})
|
|
try:
|
|
snap = zlib.decompress(row[1]) # already-JSON bytes: {"t":..,"planes":[...]}
|
|
except zlib.error:
|
|
return self._send_json(500, {"error": "corrupt snapshot"})
|
|
return self._send(200, "application/json", snap, {"X-Godsigh-History": "hit"})
|
|
|
|
def log_message(self, fmt, *args):
|
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8137
|
|
print(f"GODSIGH serving on http://127.0.0.1:{port}")
|
|
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|