#!/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? → https://ssd.jpl.nasa.gov/api/horizons.api? /proxy/sbdb? → https://ssd-api.jpl.nasa.gov/sbdb_query.api? /proxy/cad? → https://ssd-api.jpl.nasa.gov/cad.api? 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= with base64 PNG (or a # data:image/png;base64,... URL) → docs/.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()