solargod/serve.py
monsterrobotparty 4360269ad3 🔧 review: fix 8 findings from adversarial self-review (opus)
Multi-agent adversarial review (verified findings) — the top finding (dead orbit
drag) fable had already fixed in 3e36a0c; these are the rest, composing on top of
fable's 228caf2/3e36a0c:

- scale.js viewFromEcl: allocation-free + alias-safe (read x/y/z into locals before
  writing out) — it ran per body per frame and allocated an object each call (§11).
- planets.js + comets.js: refill orbit paths on any P change, not just
  isTransitioning() — the MEGA↔TRUE tween's FINAL frame (P settled, tween nulled)
  was skipped, leaving body/orbit desync (§12).
- almanac.js: a moon's heliocentric position adds its true orbital offset, so the
  Moon's "From Earth" is its real ~384,400 km / 1.3 s light, not 0.
- asteroids.js: guard refresh() against null geometry when toggled on before the
  SBDB load completes (was a TypeError).
- serve.py: CAD close-approach responses are never cached immutable — the database
  keeps growing for past dates as new NEOs are discovered (only Horizons ephemeris
  is truly immutable).

Re-verified: 13/13 gates green; orbit zoom dollies 82→73 finite; Moon From Earth
0.003 AU; layer toggles throw nothing; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:43:40 +10:00

230 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""SOLARGOD dev server: static files + a same-origin proxy for the JPL feeds a
browser can't call directly (no CORS on ssd.jpl.nasa.gov / ssd-api.jpl.nasa.gov).
Zero dependencies (stdlib only). Quickstart: python3 serve.py → http://127.0.0.1:8147
Proxy endpoints (allowlisted upstreams only — never a general proxy):
/proxy/horizons?<q> → https://ssd.jpl.nasa.gov/api/horizons.api?<q>
/proxy/sbdb?<q> → https://ssd-api.jpl.nasa.gov/sbdb_query.api?<q>
/proxy/cad?<q> → https://ssd-api.jpl.nasa.gov/cad.api?<q>
Disk cache in cache/ keyed by sha256(upstream URL). Responses whose requested
time span lies entirely in the past are immutable (never expire); everything else
gets a 24 h TTL. The cache is the rate limiter — be polite to JPL (brief §6, §12).
"""
import base64
import hashlib
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8147
ROOT = Path(__file__).resolve().parent
CACHE_DIR = ROOT / "cache"
CACHE_TTL_SEC = 24 * 3600
UPSTREAMS = {
"horizons": "https://ssd.jpl.nasa.gov/api/horizons.api",
"sbdb": "https://ssd-api.jpl.nasa.gov/sbdb_query.api",
"cad": "https://ssd-api.jpl.nasa.gov/cad.api",
}
JD_UNIX_EPOCH = 2440587.5
def _jd_to_unix(jd):
return (jd - JD_UNIX_EPOCH) * 86400.0
def _parse_epoch_to_unix(tok):
"""Best-effort: a Horizons/CAD time token → unix seconds, or None."""
tok = tok.strip().strip("'\"")
if not tok:
return None
try: # bare Julian Date (e.g. 2461236.5)
f = float(tok)
if f > 2400000:
return _jd_to_unix(f)
except ValueError:
pass
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y-%b-%d %H:%M", "%Y-%b-%d"):
try:
return datetime.strptime(tok, fmt).replace(tzinfo=timezone.utc).timestamp()
except ValueError:
continue
return None
def _requested_span_is_past(name, query):
"""True if the whole requested time span is safely in the past → immutable."""
q = urllib.parse.parse_qs(query, keep_blank_values=True)
latest = None
def bump(u):
nonlocal latest
if u is not None:
latest = u if latest is None else max(latest, u)
if name == "horizons":
for key in ("STOP_TIME", "TLIST"):
for raw in q.get(key, []):
for tok in re.split(r"[,\s]+", raw):
bump(_parse_epoch_to_unix(tok))
if "STOP_TIME" not in q and "TLIST" not in q:
return False
# CAD is NOT immutable even for past dates: close-approach records keep growing
# as new NEOs are discovered. SBDB elements advance too. Both get the 24 h TTL.
else:
return False
# Margin: only call it immutable if the latest epoch is > 1 day ago.
return latest is not None and latest < time.time() - 86400
def _cache_paths(url):
h = hashlib.sha256(url.encode()).hexdigest()
return CACHE_DIR / f"{h}.body", CACHE_DIR / f"{h}.meta"
def _cache_read(url):
body_p, meta_p = _cache_paths(url)
try:
meta = json.loads(meta_p.read_text())
body = body_p.read_bytes()
except (OSError, ValueError):
return None
if meta.get("immutable"):
return body, meta.get("ctype", "text/plain")
if time.time() - meta.get("ts", 0) < CACHE_TTL_SEC:
return body, meta.get("ctype", "text/plain")
return None
def _cache_write(url, body, ctype, immutable):
body_p, meta_p = _cache_paths(url)
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
_atomic_write(body_p, body)
_atomic_write(meta_p, json.dumps(
{"ts": time.time(), "immutable": bool(immutable), "ctype": ctype,
"url": url}).encode())
except OSError:
pass # caching must never break the actual response
def _atomic_write(path, data):
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(ROOT), **kw)
def end_headers(self):
# Dev server: never cache static assets so js/css edits always take effect.
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if self.path.startswith("/proxy/"):
return self._serve_proxy()
return super().do_GET()
def _serve_proxy(self):
name, _, query = self.path[len("/proxy/"):].partition("?")
base = UPSTREAMS.get(name)
if not base:
return self._send_json(404, {"error": f"unknown upstream {name!r}"})
url = base + ("?" + query if query else "")
cached = _cache_read(url)
if cached is not None:
return self._send(200, cached[1], cached[0], {"X-Solargod-Cache": "hit"})
try:
req = urllib.request.Request(url, headers={"User-Agent": "solargod-dev/0.1"})
with urllib.request.urlopen(req, timeout=60) as r:
body = r.read()
ctype = r.headers.get("Content-Type", "text/plain")
# Horizons returns HTTP 200 even for "no ephemeris" errors — don't cache
# those (a transient/burst failure would otherwise poison the cache). A
# real ephemeris always contains the $$SOE marker.
if name != "horizons" or b"$$SOE" in body:
_cache_write(url, body, ctype, _requested_span_is_past(name, query))
return self._send(200, ctype, body, {"X-Solargod-Cache": "miss"})
except urllib.error.HTTPError as e:
body = e.read() or str(e).encode()
return self._send(e.code, "text/plain", body, {"X-Solargod-Cache": "err"})
except Exception as e:
return self._send(502, "text/plain", str(e).encode(), {"X-Solargod-Cache": "err"})
def do_POST(self):
# Dev-only screenshot sink: POST /snap?name=<n> with base64 PNG (or a
# data:image/png;base64,... URL) → docs/<n>.png. Bound to 127.0.0.1; the
# name is sanitized so the write can never escape docs/.
if not (self.path == "/snap" or self.path.startswith("/snap?")):
return self.send_error(404, "not found")
_, _, query = self.path.partition("?")
raw = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
name = re.sub(r"[^a-z0-9-]", "", raw.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"})
if not png:
return self._send_json(400, {"error": "empty image"})
docs = ROOT / "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(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-Solargod-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 _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra)
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
if __name__ == "__main__":
CACHE_DIR.mkdir(parents=True, exist_ok=True)
print(f"SOLARGOD serving on http://127.0.0.1:{PORT} (proxy: horizons · sbdb · cad · cache→cache/)")
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()