Godstrument/hub.py
type-two 510ddaaf8c 🔧 review fixes — the five-angle audit's whole findings list
Correctness (four were my own regressions from the REVELATION wave):
· ☸ WHEELS polymeter permanently detuned after any skipped frame —
  seqAbsStep advanced by ++ per transition, but one frame can cross two
  16ths (bpm ≳155, or the clamped dt on every tab refocus). Proven to
  drift 12 steps out at 174bpm; now advances by the true distance, so
  seqAbsStep % 16 === seqCurStep always holds.
· Sample & hold with exactly one lit step froze on its first sample
  forever (guard keyed on the wrapped step, which never changed) — now
  keyed on the absolute step.
· PSALM double-activation left the MICROPHONE HOT after "stop": the
  re-entrancy guard sat after the getUserMedia await, orphaning the
  first stream/interval/context. Guard is now synchronous, a stop
  during the prompt is honoured, and a second tap while starting
  cancels. Mic off means off — the promise the feature makes.
· Live-recording a drum tap into a short wheel wrote steps past wlen
  that never played; the tap now folds onto the lane's own wheel.
· Switching MIDI out port left notes droning on the old port (pending
  off-timers fired at the NEW one) — midiFlushOut() releases every held
  note + all-notes-off before the swap.
· world_quakes' seen-set grew unbounded on long-running rigs.

Security: stored XSS via the commune room name — the one untrusted
string reaching innerHTML unescaped (hub only lower-cased it). Escaped
client-side at both sinks, and the hub now strips markup chars too.

Robustness: world_ephemeris/world_almanac imported swisseph unguarded,
so a machine without pyswisseph crash-looped them every 10s forever
against the supervisor's exit-0 contract — both now retire quietly
(verified by simulating the missing dep), and pyswisseph is documented
in requirements.

Mobile: the top bar's five separately-pinned chips collided below
~500px — the first thing a phone visitor saw was a broken header. Now
a clean row with the account chip on its own line and the source
counter moved left.

Housekeeping: dead measureSpaced(), dead #account .code selector, and
an 888K stale worktree removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:43:20 +10:00

749 lines
32 KiB
Python

"""
hub.py — the traffic cop and the brain.
Everything (your hand, the room, a satellite, bitcoin) fires OSC at this hub on
127.0.0.1:9000. It doesn't care where a signal came from — a fingertip and a
plane over Tokyo are the same kind of thing here. The hub:
1. receives every OSC message into a thread-safe raw state,
2. on a fixed control-rate clock, normalizes + One-Euro-filters each signal
to a clean 0..1 (events become decaying impulses),
3. runs the modulation matrix (matrix.py) to turn signals into destinations,
4. emits results as OSC /out/<dest> (for Ableton / TouchDesigner), optional
MIDI CC/notes, and a live WebSocket feed for the browser visualizer.
Run: python hub.py (uses config.json)
python hub.py --config myset.json
Ports: OSC in 9000 | OSC out 9001 | WebSocket 8765 | HTTP viz 8088
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import queue
import re
import threading
import time
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import websockets
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import ThreadingOSCUDPServer
from pythonosc.udp_client import SimpleUDPClient
from normalize import AdaptiveNormalizer, OneEuroFilter, ImpulseEnvelope
from matrix import ModMatrix
from transform import TweakBank
HERE = os.path.dirname(os.path.abspath(__file__))
START = time.monotonic()
# sidereal orbital periods in Earth-years — a group assigned a planet cycles at
# this rate relative to `orbit_base_seconds` (one Earth orbit = base seconds).
PLANET_PERIODS = {
"mercury": 0.2408, "venus": 0.6152, "earth": 1.0, "mars": 1.8808,
"jupiter": 11.862, "saturn": 29.457, "uranus": 84.02,
"neptune": 164.8, "pluto": 248.0,
}
def now() -> float:
return time.monotonic() - START
# ---------------------------------------------------------------------------
# Per-signal processing chain
# ---------------------------------------------------------------------------
class Signal:
def __init__(self, name: str, spec: dict):
self.name = name
self.type = spec.get("type", "continuous")
self.label = spec.get("label", name)
nrm = spec.get("norm", {})
mode = nrm.get("mode", "minmax")
fixed = None
if "lo" in nrm and "hi" in nrm:
fixed = (nrm["lo"], nrm["hi"])
mode = "fixed"
elif not nrm and (name.startswith(("midi.", "hand.", "tof.", "light."))
or self.type == "event"):
# Control inputs (knobs, hand, sensors) and bare events already
# speak 0..1 — adaptively rescaling a constant stream would pin it
# to 0.5 (a held knob) or halve every event. Pass them through.
# ponytail: prefix allowlist; give any exception an explicit norm.
fixed = (0.0, 1.0)
mode = "fixed"
self.norm = AdaptiveNormalizer(nrm.get("window", 600), mode, fixed)
flt = spec.get("filter", {})
self.filter = OneEuroFilter(flt.get("min_cutoff", 1.2),
flt.get("beta", 0.02))
self.env = ImpulseEnvelope(spec.get("attack", 0.01),
spec.get("decay", 0.6))
self.raw = 0.0
self.value = 0.0
def update_continuous(self, raw: float, t: float) -> float:
self.raw = raw
n = self.norm(raw)
self.value = self.filter(n, t)
return self.value
def trigger(self, magnitude: float, t: float):
self.raw = magnitude
self.env.trigger(self.norm(magnitude), t)
def event_value(self, t: float) -> float:
self.value = self.env.value(t)
return self.value
# ---------------------------------------------------------------------------
# The hub
# ---------------------------------------------------------------------------
class Hub:
def __init__(self, cfg: dict):
self.cfg = cfg
self.specs = cfg.get("signals", {})
self.matrix = ModMatrix.from_config(cfg)
self.tweaks = TweakBank(cfg)
self.rate = cfg.get("control_rate_hz", 60)
# read-only mode: broadcast to viewers but ignore every incoming command
# (the ws control channel has no auth — safe to expose publicly this way).
# Set via config "readonly": true or env GODSTRUMENT_READONLY=1.
self.readonly = bool(cfg.get("readonly")) or \
os.environ.get("GODSTRUMENT_READONLY", "").strip().lower() in ("1", "true", "yes", "on")
self.signals: dict[str, Signal] = {}
self._raw_lock = threading.Lock()
self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t)
self._last_raw: dict[str, float] = {} # last value fed to the normalizer
self._event_q: "queue.Queue[tuple[str,float,float]]" = queue.Queue()
self._godspeak_q: "queue.Queue[dict]" = queue.Queue() # 🗣 GODSPEAK text/state → clients (a voice command channel, not a signal)
self.out = SimpleUDPClient(cfg.get("osc_out_host", "127.0.0.1"),
cfg.get("osc_out_port", 9001))
self.warp = cfg.get("warp", {})
self._jog_clients: dict[int, SimpleUDPClient] = {}
self.orbit_base = cfg.get("orbit_base_seconds", 60)
self.patches_dir = os.path.join(HERE, "patches")
self._patches_dirty = False
self._scan_patches()
self.midi = _open_midi(cfg.get("midi", {}))
self.clients: set = set()
self.cnames: dict = {} # ws -> identity (session username, or claimed on local hubs)
self.communes: dict = {} # room -> {host, members:set, invited:set, alloc:{section:user}}
self.recent_events: list[dict] = []
self._last_notes: dict[str, int] = {}
self.route_meta = [
{"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", "")}
for r in cfg.get("routes", [])
]
# ---- OSC ingest (runs in the OSC server thread) --------------------
def _osc_handler(self, address: str, *args):
# 🗣 GODSPEAK — text/state, not a norm signal: relay it straight to clients.
# Muted entirely in read-only mode (voice is a command channel, and the
# ws control path has no auth). Queued here (OSC thread) → drained and
# broadcast in control_loop (asyncio thread), like every other OSC ingest.
if address == "/godspeak/rec" or address == "/godspeak":
if self.readonly:
return
if address == "/godspeak/rec":
try:
rec = 1 if (args and float(args[0]) >= 0.5) else 0
except (TypeError, ValueError):
rec = 0
self._godspeak_q.put({"rec": rec})
elif args:
text = str(args[0]).strip()
if text:
self._godspeak_q.put({"text": text})
return
if not args:
return
try:
val = float(args[0])
except (TypeError, ValueError):
return
# /gs/a/b -> "a.b" ; /gs/quake/event -> event on "quake.event"
parts = address.strip("/").split("/")
if parts and parts[0] == "gs":
parts = parts[1:]
key = ".".join(parts)
t = now()
if key.endswith(".event"):
self._event_q.put((key, val, t))
else:
with self._raw_lock:
self._raw_inbox[key] = (val, t)
def _ensure_signal(self, key: str) -> Signal:
sig = self.signals.get(key)
if sig is None:
spec = self.specs.get(key, {})
if key.endswith(".event") and "type" not in spec:
spec = {**spec, "type": "event"}
sig = Signal(key, spec)
self.signals[key] = sig
return sig
# ---- control-rate loop (runs in asyncio) --------------------------
async def control_loop(self):
period = 1.0 / self.rate
while True:
t = now()
# drain events -> triggers
while True:
try:
key, mag, et = self._event_q.get_nowait()
except queue.Empty:
break
sig = self._ensure_signal(key)
sig.trigger(mag, et)
ev = {"src": key.rsplit(".", 1)[0], "mag": round(mag, 3), "t": et}
self.recent_events.append(ev)
self.recent_events = self.recent_events[-40:]
# 🗣 GODSPEAK — relay queued voice text/state to clients immediately.
# Always drain (so it can't grow unbounded); broadcast only if anyone's listening.
while True:
try:
obj = self._godspeak_q.get_nowait()
except queue.Empty:
break
if self.clients:
websockets.broadcast(self.clients, json.dumps({"godspeak": obj}))
# snapshot raw inbox
with self._raw_lock:
inbox = dict(self._raw_inbox)
sources: dict[str, float] = {}
wire_sources: dict[str, dict] = {}
for key, (raw, rt) in inbox.items():
sig = self._ensure_signal(key)
if self._last_raw.get(key) != raw:
# only feed the normalizer/filter when the value actually
# changed. Workers re-emit their last value on a cadence with
# a fresh timestamp each time, so gating on arrival time would
# still pad an adaptive window with duplicates; gating on the
# value means the window holds N distinct samples (not N ticks
# of polling). ponytail: windows now forget by sample, not
# wall-clock — the right call for this instrument.
v = sig.update_continuous(raw, t)
self._last_raw[key] = raw
else:
v = sig.value # unchanged datum; hold the last value
sources[key] = v
wire_sources[key] = {"raw": round(raw, 4), "norm": round(v, 4),
"label": sig.label,
"muted": self.tweaks.is_muted(key)}
# event envelopes decay every tick
for key, sig in self.signals.items():
if sig.type == "event":
v = sig.event_value(t)
sources[key] = v
wire_sources[key] = {"raw": round(sig.raw, 4),
"norm": round(v, 4),
"label": sig.label, "event": True,
"muted": self.tweaks.is_muted(key)}
# planetary orbits — each orbit-group's gain waxes/wanes at its
# planet's real relative rate (Mercury whips, Neptune barely drifts)
for g, planet in self.tweaks.group_orbit.items():
orb = PLANET_PERIODS.get(planet, 1.0) * self.orbit_base
phase = (t / orb) % 1.0
self.tweaks.set_orbit_lfo(
g, 0.25 + 0.75 * (0.5 + 0.5 * math.sin(2 * math.pi * phase)))
# scrub any time-warped replay workers from live controls
self._forward_jog(sources)
# performance layer: live controls -> tweaks -> groups -> matrix
self.tweaks.apply_controls(sources)
tweaked = self.tweaks.transform(sources)
dests, active = self.matrix.evaluate(tweaked)
self._emit(dests)
await self._broadcast(t, wire_sources, dests, active,
self.tweaks.macros())
await asyncio.sleep(period)
# ---- time-warp jog forwarding -------------------------------------
def _forward_jog(self, sources: dict[str, float]):
for w in self.warp.values():
port = w.get("port")
if not port:
continue
cl = self._jog_clients.get(port)
if cl is None:
cl = SimpleUDPClient("127.0.0.1", port)
self._jog_clients[port] = cl
rs, ns = w.get("rate_src"), w.get("nudge_src")
if rs and rs in sources:
cl.send_message("/jog/rate", float(sources[rs]))
if ns and ns in sources:
cl.send_message("/jog/nudge", float(sources[ns]))
# ---- outputs -------------------------------------------------------
def _emit(self, dests: dict[str, float]):
midi_map = self.cfg.get("midi", {}).get("map", {})
notes_live = set() # note dests sounding this frame
for dest, val in dests.items():
self.out.send_message(f"/out/{dest}", float(val))
m = midi_map.get(dest)
if not (m and self.midi):
continue
if m.get("note"): # note-mapped: val IS a note number
note = int(round(val))
if not 24 <= note <= 127: # matches the viz Web-MIDI guard
continue
notes_live.add(dest)
if self._last_notes.get(dest) != note:
if self._last_notes.get(dest) is not None:
self.midi.send_message([0x80 | m.get("channel", 0),
self._last_notes[dest], 0])
self.midi.send_message([0x90 | m.get("channel", 0),
note, 100])
self._last_notes[dest] = note
elif "cc" in m:
cc = int(_clamp01(val) * 127)
self.midi.send_message([0xB0 | m.get("channel", 0),
m["cc"], cc])
# release any held note whose source went silent (muted/gated) this frame
if self.midi:
for dest, note in list(self._last_notes.items()):
if note is not None and dest not in notes_live:
ch = midi_map.get(dest, {}).get("channel", 0)
self.midi.send_message([0x80 | ch, note, 0])
self._last_notes[dest] = None
async def _broadcast(self, t, sources, dests, active, macros=None):
if not self.clients:
return
routes = []
for i, r in enumerate(self.matrix.routes):
routes.append({"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", ""),
"amount": round(r.get("amount", 0), 3),
"active": round(active.get(i, 0.0), 4)})
p = {
"t": round(t, 3),
"sources": sources,
"dests": {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in dests.items()},
"routes": routes,
"macros": macros or {},
"events": self.recent_events[-12:],
}
if self._patches_dirty:
p["patches"] = self.patches
self._patches_dirty = False
payload = json.dumps(p)
websockets.broadcast(self.clients, payload)
async def ws_handler(self, ws):
self.clients.add(ws)
# who is this? the session cookie rides the websocket handshake, so a
# signed-in user's identity is known here and cannot be spoofed.
try:
hdrs = getattr(ws, "request_headers", None)
if hdrs is None and getattr(ws, "request", None) is not None:
hdrs = ws.request.headers
cookie = hdrs.get("Cookie", "") if hdrs else ""
if cookie:
import auth as _auth
uid = _auth._uid_from_cookies(cookie, _auth.DEFAULT_DB)
info = _auth.user_info(uid, _auth.DEFAULT_DB) if uid else None
if info and info.get("username"):
self.cnames[ws] = info["username"]
except Exception: # noqa
pass
try:
await ws.send(json.dumps({
"hello": "godstrument",
"readonly": self.readonly,
# full route spec so a logged-in client can compute its own mix
"routes": [{"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", ""),
"amount": round(r.get("amount", 0), 3),
"curve": r.get("curve", "lin"),
"gate": r.get("gate"), "quantize": r.get("quantize")}
for r in self.matrix.routes],
"groups": self.cfg.get("groups", {}),
"midi": self.cfg.get("midi", {}).get("map", {}),
"patches": self.patches}))
async for msg in ws: # browser -> hub commands
try:
m = json.loads(msg)
c = m.get("commune")
if isinstance(c, dict):
self._commune(ws, c)
continue
self._handle_cmd(m)
except Exception: # noqa
pass
finally:
self.clients.discard(ws)
self.cnames.pop(ws, None)
# ---- the commune: hosted rooms, invitations, host-allocated sections ----
# Identity comes from the session cookie (public hub: signed-in users only);
# membership is by invitation with accept/decline; the host allocates
# sections; voice-state relays only from whoever actually holds the section.
def _commune(self, ws, c):
user = self.cnames.get(ws)
if self.readonly and not user:
websockets.broadcast([ws], json.dumps(
{"commune": {"error": "the commune is for signed-in players — log in first"}}))
return
if not user: # local/trusted hub: take the claimed name
user = str(c.get("from") or "guest")[:24]
self.cnames[ws] = user
# room names are rendered by the clients — strip anything markup-ish here too,
# so a hostile name can never reach a browser even if a client forgets to escape
room = re.sub(r"[<>&\"'`]", "", str(c.get("room") or ""))[:40].strip().lower()
R = self.communes
def out(names, obj):
targets = [w for w in self.clients if self.cnames.get(w) in names]
if targets:
websockets.broadcast(targets, json.dumps({"commune": obj}))
def roominfo(r):
g2 = R.get(r)
if g2:
out(g2["members"], {"roominfo": {"room": r, "host": g2["host"],
"members": sorted(g2["members"]), "alloc": g2["alloc"]}})
if c.get("hello"): # presence tag only
return
if c.get("create") and room:
g = R.get(room)
if g and g["members"] and user not in g["members"]:
out([user], {"error": "" + room + "” is already alive — ask its host for an invitation"})
return
R[room] = {"host": user, "members": {user}, "invited": set(), "alloc": {}}
roominfo(room)
return
g = R.get(room)
if not g:
out([user], {"error": "no such room — its host may have closed it"})
return
if c.get("invite") and user == g["host"]:
tgt = str(c["invite"]).strip()[:24]
if not any(self.cnames.get(w) == tgt for w in self.clients):
out([user], {"error": tgt + " isn't on the site right now — invitations reach the present"})
return
g["invited"].add(tgt)
out([tgt], {"invite": {"room": room, "host": user}})
out([user], {"ok": "invitation sent to " + tgt})
return
if c.get("accept"):
if user in g["invited"] or user in g["members"]:
g["invited"].discard(user)
g["members"].add(user)
roominfo(room)
else:
out([user], {"error": "no standing invitation to that room"})
return
if c.get("decline"):
g["invited"].discard(user)
out([g["host"]], {"declined": user, "room": room})
return
if user not in g["members"]:
out([user], {"error": "you are not in that room"})
return
if c.get("leave"):
g["members"].discard(user)
for s in [s for s, o in list(g["alloc"].items()) if o == user]:
del g["alloc"][s]
if user == g["host"] or not g["members"]:
out(g["members"] | {user}, {"disbanded": room})
R.pop(room, None)
else:
roominfo(room)
return
if c.get("allocate") and user == g["host"]:
a = c["allocate"] if isinstance(c["allocate"], dict) else {}
s, to = str(a.get("section", ""))[:40], str(a.get("to", ""))[:24]
if s and to in g["members"]:
g["alloc"][s] = to
roominfo(room)
return
if c.get("claim"):
s = str(c["claim"])[:40]
held = g["alloc"].get(s)
if held and held != user and user != g["host"]:
out([user], {"error": "that section is held by " + held + " — ask the host"})
else:
g["alloc"][s] = user
roominfo(room)
return
if c.get("release"):
s = str(c["release"])[:40]
if g["alloc"].get(s) == user:
del g["alloc"][s]
roominfo(room)
return
if c.get("voice") and c.get("state") is not None:
s = str(c["voice"])[:40]
if g["alloc"].get(s) == user: # only the section's holder speaks for it
out(g["members"] - {user}, {"voice": s, "state": c["state"], "from": user, "room": room})
return
# ---- named templates (patches) ------------------------------------
def _scan_patches(self):
try:
self.patches = sorted(f[:-5] for f in os.listdir(self.patches_dir)
if f.endswith(".json"))
except OSError:
self.patches = []
def _save_patch(self, name):
if not name:
return
safe = _safe_name(name) or "patch"
os.makedirs(self.patches_dir, exist_ok=True)
data = {"routes": self.matrix.routes,
"groups": self.tweaks.dump_groups(),
"state": self.tweaks.dump_state()}
with open(os.path.join(self.patches_dir, safe + ".json"), "w") as f:
json.dump(data, f, indent=2)
self._scan_patches()
self._patches_dirty = True
print(f" saved template '{safe}'")
def _load_patch(self, name):
safe = _safe_name(name) # no path traversal / absolute paths
if not safe:
return
try:
with open(os.path.join(self.patches_dir, safe + ".json")) as f:
data = json.load(f)
except (OSError, ValueError):
return
if "routes" in data:
self.matrix.routes = [dict(r) for r in data["routes"]]
if "groups" in data:
self.tweaks.load_groups(data["groups"])
if "state" in data:
self.tweaks.load_state(data["state"])
print(f" loaded spell '{safe}'")
def _handle_cmd(self, m: dict):
if self.readonly: # public spectator: ignore all control commands
return
cmd = m.get("cmd")
if cmd == "mute_toggle":
self.tweaks.toggle(m.get("target"), "mute")
elif cmd == "toggle":
self.tweaks.toggle(m.get("target"), m.get("param", "mute"))
elif cmd == "set":
self.tweaks.set_param(m.get("target"), m.get("param", "gain"),
_ffloat(m.get("value"), 0.0))
elif cmd == "add_route":
self.matrix.add_route(m.get("source"), m.get("dest"),
_ffloat(m.get("amount"), 0.5))
elif cmd == "remove_route":
self.matrix.remove_route(m.get("source"), m.get("dest"))
elif cmd == "set_route":
self.matrix.set_route_amount(m.get("source"), m.get("dest"),
_ffloat(m.get("amount"), 0.5))
elif cmd == "make_group":
self.tweaks.add_group(m.get("name"), m.get("members", []))
elif cmd == "save_patch":
self._save_patch(m.get("name"))
elif cmd == "load_patch":
self._load_patch(m.get("name"))
# ---- run -----------------------------------------------------------
async def run(self):
# OSC server thread
disp = Dispatcher()
disp.set_default_handler(self._osc_handler)
osc_srv = ThreadingOSCUDPServer(
(self.cfg.get("osc_in_host", "127.0.0.1"),
self.cfg.get("osc_in_port", 9000)), disp)
threading.Thread(target=osc_srv.serve_forever, daemon=True).start()
# HTTP static server for the visualizer
_serve_http(os.path.join(HERE, "viz"),
self.cfg.get("http_port", 8080))
ws_port = self.cfg.get("ws_port", 8765)
print("╔══════════════════════════════════════════════╗")
print("║ GODSTRUMENT hub online ")
print(f"║ OSC in udp/{self.cfg.get('osc_in_port',9000)} "
f"OSC out udp/{self.cfg.get('osc_out_port',9001)}")
print(f"║ WebSocket ws/{ws_port}")
print(f"║ Visualizer -> http://localhost:"
f"{self.cfg.get('http_port',8080)}")
print(f"║ MIDI out: {'connected' if self.midi else 'off (optional)'}")
print(f"{len(self.route_meta)} routes patched, "
f"clock {self.rate}Hz")
print("╚══════════════════════════════════════════════╝")
async with websockets.serve(self.ws_handler, "127.0.0.1", ws_port):
await self.control_loop()
def _open_midi(midi_cfg: dict):
if not midi_cfg.get("enabled"):
return None
try:
import rtmidi
out = rtmidi.MidiOut()
ports = out.get_ports()
target = midi_cfg.get("port_name", "IAC")
idx = next((i for i, p in enumerate(ports) if target.lower() in p.lower()),
None)
if idx is None and ports:
idx = 0
if idx is None:
out.open_virtual_port("Godstrument")
else:
out.open_port(idx)
print(f" MIDI -> {ports[idx]}")
return out
except Exception as e: # noqa
print(f" MIDI unavailable ({e}); continuing without it.")
return None
def _serve_http(directory: str, port: int):
try:
import auth # invite-only accounts + presets, mounted at /api/*
except Exception as e: # noqa
auth = None
print(f" (accounts disabled: {e})")
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **k):
super().__init__(*a, directory=directory, **k)
def log_message(self, *a, **k):
pass
def end_headers(self):
# never let a browser serve a stale console after a deploy
if not self.path.startswith("/api/"):
self.send_header("Cache-Control", "no-cache, must-revalidate")
super().end_headers()
def _api(self, method):
path = self.path.split("?", 1)[0]
if auth is None:
return self.send_error(503, "accounts unavailable")
# CSRF: a browser always sends Origin on a cross-site state-changing request.
# Block those (incl. login-CSRF); a same-origin request or a non-browser
# client (no Origin) passes. Allowlist the canonical host + localhost so a
# legitimate request always passes even if the tunnel rewrites Host.
if method in ("POST", "PUT", "DELETE"):
origin = self.headers.get("Origin", "")
if origin:
from urllib.parse import urlparse
o = urlparse(origin)
ok = (o.hostname in ("godstrument.pro", "localhost", "127.0.0.1")
or o.netloc == self.headers.get("Host", ""))
if not ok:
blob = json.dumps({"error": "cross-site request blocked"}).encode()
self.send_response(403)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(blob)))
self.end_headers(); self.wfile.write(blob)
return
length = int(self.headers.get("Content-Length", 0) or 0)
if length > 512 * 1024:
return self.send_error(413, "too large")
body = self.rfile.read(length) if length else b""
try:
status, resp, set_cookie = auth.handle_api(
method, path, body, self.headers.get("Cookie", ""))
except Exception: # never leak a stack trace to the client
status, resp, set_cookie = 500, {"error": "server error"}, None
payload = json.dumps(resp).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
if set_cookie:
self.send_header("Set-Cookie", set_cookie)
self.end_headers()
self.wfile.write(payload)
def _force_https(self):
# visitors on http can't keep a Secure session cookie -> send them to
# https (Cloudflare sets X-Forwarded-Proto to the visitor's scheme).
if self.headers.get("X-Forwarded-Proto", "").lower() == "http":
host = self.headers.get("Host", "")
if host:
self.send_response(301)
self.send_header("Location", f"https://{host}{self.path}")
self.send_header("Content-Length", "0")
self.end_headers()
return True
return False
def do_GET(self):
if self._force_https():
return
self._api("GET") if self.path.startswith("/api/") else super().do_GET()
def do_POST(self):
self._api("POST") if self.path.startswith("/api/") else self.send_error(501)
def do_PUT(self):
self._api("PUT") if self.path.startswith("/api/") else self.send_error(501)
def do_DELETE(self):
self._api("DELETE") if self.path.startswith("/api/") else self.send_error(501)
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
def _clamp01(x: float) -> float:
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
def _safe_name(name) -> str:
"""A patch name safe to use as a filename: basename only, alnum/-/_/space.
Defeats path traversal ('../x') and absolute paths from the ws command."""
base = os.path.basename(str(name or ""))
return "".join(c for c in base if c.isalnum() or c in "-_ ").strip()
def _ffloat(x, default: float = 0.0) -> float:
"""float() that rejects NaN/Infinity (json.loads accepts them) so a crafted
command can't poison the matrix or blind the console."""
try:
v = float(x)
except (TypeError, ValueError):
return default
return v if math.isfinite(v) else default
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(HERE, "config.json"))
args = ap.parse_args()
with open(args.config) as f:
cfg = json.load(f)
hub = Hub(cfg)
try:
asyncio.run(hub.run())
except KeyboardInterrupt:
print("\n godstrument sleeps.")
if __name__ == "__main__":
main()