Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
8.8 KiB
Python
206 lines
8.8 KiB
Python
"""HTTP request handler: small routing tables (GET by exact path / prefix, POST by path)."""
|
|
import json
|
|
import re
|
|
from http.server import BaseHTTPRequestHandler
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from radio.config import HOME_PROXY, LAN_PREFIX, MAX_LIST, PORT, SCANNER_UNIT
|
|
from radio.recordings import counts, find_item, scan
|
|
from web import audio, events
|
|
from web.pages import FAVICON, SHARE_PAGE, page
|
|
from web.shares import share_conv, share_token
|
|
from web.sources import adsb_latest, promote, sources
|
|
from web.speaker import speaker_set, speaker_state
|
|
from web.status import status, transcribe_status, unit_state
|
|
|
|
JSON_UTF8 = "application/json; charset=utf-8"
|
|
|
|
|
|
def qint(qs, k, d, lo, hi):
|
|
try:
|
|
return max(lo, min(hi, int(qs.get(k, [d])[0])))
|
|
except ValueError:
|
|
return d
|
|
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
server_version = "sdr-web/2"
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, fmt, *a):
|
|
pass
|
|
|
|
# ---- helpers ------------------------------------------------------------------------------
|
|
def send(self, code, body, ctype="text/html; charset=utf-8", extra=None):
|
|
b = body.encode("utf-8") if isinstance(body, str) else body
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(b)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
for k, v in (extra or {}).items():
|
|
self.send_header(k, v)
|
|
self.end_headers()
|
|
if self.command != "HEAD":
|
|
self.wfile.write(b)
|
|
|
|
def from_lan(self):
|
|
"""Client is on the home LAN: its own address, or (via the home Caddy) the X-Home-Client it passes on."""
|
|
ip = self.client_address[0]
|
|
if ip == HOME_PROXY:
|
|
ip = (self.headers.get("X-Home-Client") or "").strip()
|
|
return ip.startswith(LAN_PREFIX)
|
|
|
|
def js(self, data, cors=True):
|
|
return self.send(200, json.dumps(data, ensure_ascii=False), JSON_UTF8,
|
|
{"Access-Control-Allow-Origin": "*"} if cors else None)
|
|
|
|
# ---- GET routes: handler(self, qs) --------------------------------------------------------
|
|
def r_index(self, qs):
|
|
return self.send(200, page())
|
|
|
|
def r_favicon(self, qs):
|
|
return self.send(200, FAVICON, "image/svg+xml", {"Cache-Control": "public, max-age=604800"})
|
|
|
|
def r_list(self, qs):
|
|
voice = qs.get("voice", ["1"])[0] != "0"
|
|
gs = [g for g in qs.get("groups", [""])[0].split(",") if g] or None
|
|
before = qs.get("before", [""])[0] or None
|
|
chan = re.sub(r"\D", "", qs.get("chan", [""])[0]) or None
|
|
items, pending = scan(qint(qs, "limit", 400, 1, MAX_LIST), voice, None, "", True, groups=gs, before=before, chan=chan)
|
|
return self.js({"items": items, "pending": pending, "status": status(), "counts": counts(voice),
|
|
"more": len(items) >= qint(qs, "limit", 400, 1, MAX_LIST)}, cors=False)
|
|
|
|
def r_recent(self, qs):
|
|
g = qs.get("group", [None])[0]
|
|
noise = qs.get("noise", ["0"])[0] == "1"
|
|
host = self.headers.get("Host") or f"192.168.88.35:{PORT}"
|
|
items, _ = scan(qint(qs, "limit", 20, 1, 500), not noise, g,
|
|
"http://" + re.sub(r"[^A-Za-z0-9.:\[\]-]", "", host))
|
|
return self.js(items)
|
|
|
|
def r_status(self, qs):
|
|
return self.js(status())
|
|
|
|
def r_transcribe(self, qs):
|
|
"""For the home dashboard's health check: 200 while Whisper on the desktop answers, else 503."""
|
|
t = transcribe_status() or {"stale": True}
|
|
ok = t.get("endpoint_up") and not t.get("stale")
|
|
return self.send(200 if ok else 503, json.dumps(t, ensure_ascii=False), JSON_UTF8)
|
|
|
|
def r_adsb(self, qs):
|
|
return self.js(adsb_latest(), cors=False)
|
|
|
|
def r_sources(self, qs):
|
|
return self.js(sources(), cors=False)
|
|
|
|
def r_item(self, qs):
|
|
it = find_item(qs.get("id", [""])[0])
|
|
if it is None:
|
|
return self.send(404, json.dumps({"error": "gone"}), JSON_UTF8)
|
|
return self.js(it, cors=False)
|
|
|
|
def r_events(self, qs):
|
|
return events.stream(self)
|
|
|
|
def r_speaker(self, qs):
|
|
return self.js(dict(speaker_state(), local=self.from_lan()), cors=False)
|
|
|
|
def r_mode(self, qs):
|
|
# legacy alias, kept for old clients
|
|
s = unit_state()
|
|
return self.js({"mode": "scanner" if s in ("active", "activating", "reloading") else "off",
|
|
"units": {SCANNER_UNIT: s}, "switching": False})
|
|
|
|
def r_share(self, qs, path):
|
|
return self.share_get(path.split("/")[2:], qs.get("dl", ["0"])[0] == "1")
|
|
|
|
def r_audio(self, qs, path):
|
|
return audio.serve(self, path[len("/audio/"):], qs.get("dl", ["0"])[0] == "1")
|
|
|
|
GET_EXACT = {
|
|
"/": r_index, "/favicon.svg": r_favicon, "/favicon.ico": r_favicon,
|
|
"/api/list": r_list, "/api/recent": r_recent, "/api/status": r_status, "/api/transcribe": r_transcribe, "/api/adsb": r_adsb,
|
|
"/api/sources": r_sources, "/api/item": r_item, "/api/events": r_events,
|
|
"/api/speaker": r_speaker, "/api/mode": r_mode,
|
|
}
|
|
GET_PREFIX = (("/s/", r_share), ("/audio/", r_audio))
|
|
|
|
def do_HEAD(self):
|
|
self.do_GET()
|
|
|
|
def do_GET(self):
|
|
u = urlparse(self.path)
|
|
qs = parse_qs(u.query)
|
|
try:
|
|
fn = self.GET_EXACT.get(u.path)
|
|
if fn:
|
|
return fn(self, qs)
|
|
for prefix, fn in self.GET_PREFIX:
|
|
if u.path.startswith(prefix):
|
|
return fn(self, qs, u.path)
|
|
return self.send(404, "not found", "text/plain")
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
def share_get(self, parts, dl):
|
|
conv = share_conv(parts[0] if parts else "")
|
|
if conv is None:
|
|
if len(parts) == 1:
|
|
return self.send(200, SHARE_PAGE, extra={"X-Robots-Tag": "noindex"})
|
|
return self.send(404, json.dumps({"error": "gone"}), JSON_UTF8)
|
|
items, me = conv
|
|
if len(parts) == 1:
|
|
return self.send(200, SHARE_PAGE, extra={"X-Robots-Tag": "noindex"})
|
|
if parts[1:] == ["data"]:
|
|
keep = ("channel", "group", "freq_mhz", "ts", "duration_s", "peaks")
|
|
return self.js({"me": me, "items": [{k: x.get(k) for k in keep} for x in items]}, cors=False)
|
|
if len(parts) == 3 and parts[1] == "a" and parts[2].isdigit() and int(parts[2]) < len(items):
|
|
return audio.serve(self, items[int(parts[2])]["file"], dl)
|
|
return self.send(404, "not found", "text/plain")
|
|
|
|
# ---- POST routes: handler(self, body) -----------------------------------------------------
|
|
def p_share(self, body):
|
|
try:
|
|
rid = str(json.loads(body.decode("utf-8") or "{}").get("id") or "")
|
|
except (ValueError, AttributeError):
|
|
rid = ""
|
|
it = find_item(rid)
|
|
if not it:
|
|
return self.send(404, json.dumps({"ok": False, "msg": "запись не найдена"}, ensure_ascii=False), JSON_UTF8)
|
|
return self.js({"ok": True, "url": f"/s/{share_token(it['id'])}"}, cors=False)
|
|
|
|
def p_speaker(self, body):
|
|
if not self.from_lan():
|
|
return self.send(403, json.dumps({"ok": False, "msg": "только из домашней сети"}, ensure_ascii=False),
|
|
JSON_UTF8)
|
|
try:
|
|
b = json.loads(body.decode("utf-8") or "{}")
|
|
st = speaker_set(b.get("on"), b.get("volume"))
|
|
except (ValueError, TypeError, AttributeError, OSError) as e:
|
|
return self.send(400, json.dumps({"ok": False, "msg": str(e)}), "application/json")
|
|
return self.js(dict(st, local=True), cors=False)
|
|
|
|
def p_promote(self, body):
|
|
try:
|
|
freq = int(json.loads(body.decode("utf-8") or "{}").get("f"))
|
|
except (ValueError, TypeError, AttributeError):
|
|
return self.send(400, json.dumps({"ok": False, "msg": "bad request"}), "application/json")
|
|
code, msg = promote(freq)
|
|
return self.send(code, json.dumps({"ok": code == 200, "msg": msg}, ensure_ascii=False), JSON_UTF8)
|
|
|
|
POST_ROUTES = {"/api/promote": p_promote, "/api/speaker": p_speaker, "/api/share": p_share}
|
|
|
|
def do_POST(self):
|
|
# mode switching was removed on purpose: the radio site is recordings-only.
|
|
# Writes: queue a discovered frequency, speaker switch (LAN only), create a share link.
|
|
u = urlparse(self.path)
|
|
n = min(int(self.headers.get("Content-Length") or 0), 4096)
|
|
body = self.rfile.read(n) if n else b""
|
|
fn = self.POST_ROUTES.get(u.path)
|
|
if fn is None:
|
|
return self.send(405, "method not allowed", "text/plain", {"Allow": "GET, HEAD"})
|
|
origin = self.headers.get("Origin")
|
|
if origin and urlparse(origin).netloc != self.headers.get("Host", ""):
|
|
return self.send(403, "forbidden", "text/plain")
|
|
return fn(self, body)
|