ad9ee4f057
Replace bare assert (stripped under python -O) with an explicit isinstance check that returns HTTP 400 when routines is missing or not a list. JSON-decode errors still caught by the outer try.
234 lines
10 KiB
Python
234 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Always-on Lisael Box content service on Tower:
|
|
- POST /register {id,have} (machine: box announces, we push the delta; LAN/tailnet only)
|
|
- Merlin Studio web UI (GET /) + /api/* (human, behind Basic Auth)
|
|
"""
|
|
import base64, ipaddress, json, os, threading, urllib.parse
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import lisael_content as C
|
|
|
|
PORT = int(os.environ.get("LISAEL_PORT", "9555"))
|
|
UI_HTML = os.path.join(os.path.dirname(os.path.abspath(__file__)), "merlin_ui.html")
|
|
AUTH = os.environ.get("MERLIN_AUTH", "") # "user:pass"; empty disables auth
|
|
|
|
def _is_private(ip):
|
|
try:
|
|
a = ipaddress.ip_address(ip)
|
|
return a.is_private or a.is_loopback or ip.startswith("100.") # 100.x = tailnet
|
|
except ValueError:
|
|
return False
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
# --- helpers ---
|
|
def _json(self, obj, code=200):
|
|
b = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(code); self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(b))); self.end_headers()
|
|
self.wfile.write(b)
|
|
|
|
def _auth_ok(self):
|
|
if not AUTH:
|
|
return True
|
|
h = self.headers.get("Authorization", "")
|
|
if h.startswith("Basic "):
|
|
try:
|
|
if base64.b64decode(h[6:]).decode() == AUTH:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
self.send_response(401)
|
|
self.send_header("WWW-Authenticate", 'Basic realm="Merlin Studio"')
|
|
self.end_headers()
|
|
return False
|
|
|
|
def _body(self):
|
|
n = int(self.headers.get("Content-Length", 0) or 0)
|
|
return self.rfile.read(n) if n else b""
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
# --- GET ---
|
|
def do_GET(self):
|
|
path = self.path.split("?")[0]
|
|
if path == "/register":
|
|
self.send_error(405); return
|
|
if not self._auth_ok():
|
|
return
|
|
if path == "/" or path == "/index.html":
|
|
try:
|
|
data = open(UI_HTML, "rb").read()
|
|
except OSError:
|
|
self.send_error(500, "UI missing"); return
|
|
self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(data))); self.end_headers()
|
|
self.wfile.write(data); return
|
|
if path == "/api/feeds":
|
|
self._json(C.load_feeds()); return
|
|
if path == "/api/episodes":
|
|
q = urllib.parse.parse_qs(self.path.split("?", 1)[1] if "?" in self.path else "")
|
|
key = (q.get("feed") or [""])[0]
|
|
feed = next((f for f in C.load_feeds() if f["key"] == key), None)
|
|
if not feed:
|
|
self._json({"error": "unknown feed"}, 404); return
|
|
try:
|
|
eps, cov = C.fetch_episodes(feed["rss"])
|
|
except Exception as e:
|
|
self._json({"error": str(e)}, 502); return
|
|
staged = set(C.staging_files())
|
|
for e in eps:
|
|
e["staged"] = C.episode_fname(key, e["url"]) in staged
|
|
self._json({"feed": feed, "cover": cov, "episodes": eps}); return
|
|
if path == "/api/files":
|
|
out = []
|
|
for f in C.staging_files():
|
|
p = os.path.join(C.STAGING, f)
|
|
out.append({"name": f, "size": os.path.getsize(p),
|
|
"type": f.rsplit(".", 1)[-1] if "." in f else ""})
|
|
self._json(out); return
|
|
if path == "/api/box":
|
|
st = C.box_state_load()
|
|
delta = C.compute_delta(C.staging_files(), st.get("have", []))
|
|
self._json({"box": st, "delta": delta, "staging_count": len(C.staging_files())}); return
|
|
if path == "/api/routines":
|
|
self._json({"routines": C.routines_load()}); return
|
|
if path == "/api/transfers":
|
|
self._json({"active": C.transfers_active(),
|
|
"history": C.transfers_history()}); return
|
|
self.send_error(404)
|
|
|
|
# --- POST ---
|
|
def do_POST(self):
|
|
path = self.path.split("?")[0]
|
|
if path == "/register":
|
|
ip = self.client_address[0]
|
|
if not _is_private(ip): # block tunnel-originated abuse
|
|
self.send_error(403); return
|
|
try:
|
|
body = json.loads(self._body() or b"{}")
|
|
except Exception:
|
|
self.send_error(400); return
|
|
have = body.get("have", []) or []
|
|
C.box_state_save({"ip": ip, "id": body.get("id"),
|
|
"last_seen": self.date_time_string(), "have": have})
|
|
delta = C.compute_delta(C.staging_files(), have)
|
|
print(f"register {ip} id={body.get('id')} have={len(have)} push={len(delta)}", flush=True)
|
|
started = C.try_push(ip, delta)
|
|
r_delta = C.compute_routines_delta(have)
|
|
if r_delta:
|
|
C.try_push(ip, r_delta, subdir="routines")
|
|
self._json({"queued": len(delta) + len(r_delta), "started": started}); return
|
|
if not self._auth_ok():
|
|
return
|
|
if path == "/api/feeds":
|
|
try:
|
|
b = json.loads(self._body() or b"{}")
|
|
C.add_feed(b["key"], b["name"], b["rss"], b.get("n", 2))
|
|
except Exception as e:
|
|
self._json({"error": str(e)}, 400); return
|
|
self._json(C.load_feeds()); return
|
|
if path == "/api/select":
|
|
try:
|
|
b = json.loads(self._body() or b"{}")
|
|
key = b["feed"]; episodes = b["episodes"]
|
|
except Exception as e:
|
|
self._json({"error": str(e)}, 400); return
|
|
feed = next((f for f in C.load_feeds() if f["key"] == key), None)
|
|
if not feed:
|
|
self._json({"error": "unknown feed"}, 404); return
|
|
def work():
|
|
cover_bytes = None
|
|
try:
|
|
_, cov = C.fetch_episodes(feed["rss"], limit=1)
|
|
if cov:
|
|
cover_bytes = C.fetch(cov)
|
|
except Exception:
|
|
cover_bytes = None
|
|
for ep in episodes:
|
|
try:
|
|
C.add_episode(key, feed["name"], cover_bytes, ep["url"], ep.get("title", ""), src="pick")
|
|
cover_bytes = None
|
|
print("selected", C.episode_fname(key, ep["url"]), flush=True)
|
|
except Exception as e:
|
|
print("select fail", e, flush=True)
|
|
threading.Thread(target=work, daemon=True).start()
|
|
self._json({"queued": len(episodes)}, 202); return
|
|
if path == "/api/upload":
|
|
q = urllib.parse.parse_qs(self.path.split("?", 1)[1] if "?" in self.path else "")
|
|
show = (q.get("show") or ["Perso"])[0]
|
|
title = (q.get("title") or ["Audio"])[0]
|
|
data = self._body()
|
|
if not data:
|
|
self._json({"error": "empty"}, 400); return
|
|
try:
|
|
fname = C.add_upload(show, data, title)
|
|
except Exception as e:
|
|
self._json({"error": str(e)}, 500); return
|
|
self._json({"file": fname}); return
|
|
if path == "/api/push":
|
|
st = C.box_state_load()
|
|
ip = st.get("ip")
|
|
if not ip:
|
|
self._json({"error": "box never registered"}, 409); return
|
|
delta = C.compute_delta(C.staging_files(), st.get("have", []))
|
|
started = C.try_push(ip, delta)
|
|
self._json({"ip": ip, "delta": len(delta), "started": started}); return
|
|
if path == "/api/routines":
|
|
try:
|
|
b = json.loads(self._body() or b"{}")
|
|
except Exception as e:
|
|
self._json({"error": str(e)}, 400); return
|
|
routines = b.get("routines") if isinstance(b, dict) else None
|
|
if not isinstance(routines, list):
|
|
self._json({"error": "routines must be a list"}, 400); return
|
|
C.routines_save(routines)
|
|
n_steps = sum(len(r.get("steps", [])) for r in routines)
|
|
def work():
|
|
try:
|
|
res = C.build_routine_manifest()
|
|
print("routines built", res["generated"], "gen",
|
|
res["skipped"], "cached", flush=True)
|
|
except Exception as e:
|
|
print("routines build fail", e, flush=True)
|
|
threading.Thread(target=work, daemon=True).start()
|
|
self._json({"queued": n_steps}, 202); return
|
|
if path == "/api/routines/push":
|
|
st = C.box_state_load()
|
|
ip = st.get("ip")
|
|
if not ip:
|
|
self._json({"error": "box never registered"}, 409); return
|
|
delta = C.compute_routines_delta(st.get("have", []))
|
|
started = C.try_push(ip, delta, subdir="routines")
|
|
self._json({"ip": ip, "delta": len(delta), "started": started}); return
|
|
if path == "/api/transfers/retry":
|
|
st = C.box_state_load()
|
|
ip = st.get("ip")
|
|
if not ip:
|
|
self._json({"error": "box never registered"}, 409); return
|
|
res = C.retry_failed(ip)
|
|
if not res["count"]:
|
|
self._json({"error": "rien a relancer"}, 409); return
|
|
self._json(res, 202); return
|
|
self.send_error(404)
|
|
|
|
# --- DELETE ---
|
|
def do_DELETE(self):
|
|
if not self._auth_ok():
|
|
return
|
|
path = self.path.split("?")[0]
|
|
if path.startswith("/api/feeds/"):
|
|
C.remove_feed(urllib.parse.unquote(path[len("/api/feeds/"):]))
|
|
self._json({"ok": True}); return
|
|
if path.startswith("/api/files/"):
|
|
name = urllib.parse.unquote(path[len("/api/files/"):])
|
|
try:
|
|
C.remove_file(name)
|
|
except ValueError:
|
|
self.send_error(400); return
|
|
self._json({"ok": True}); return
|
|
self.send_error(404)
|
|
|
|
if __name__ == "__main__":
|
|
print(f"lisael-content on :{PORT} staging={C.STAGING} auth={'on' if AUTH else 'OFF'}", flush=True)
|
|
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()
|