Self-service clip intake (/clips + dumpd /me/clips via Syncthing REST) and role-aware gateway page (/about + dumpd /me)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
114
bin/dumpd.py
114
bin/dumpd.py
@@ -6,6 +6,9 @@ POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Cad
|
||||
GET /dump -> lists what a dump would move (dry run)
|
||||
GET /me/stream-token -> short-lived publish token for the caller (member of streamers/admins); the dock
|
||||
and the live portal set it as the OBS/WHIP credential. No stored passwords anywhere.
|
||||
GET /me/clips -> Syncthing self-service status for the caller: our device id, their folder/device state
|
||||
POST /me/clips -> {"device_id": "..."}: create clips/<user> (+ .stignore), add their device to our Syncthing,
|
||||
create the receive-only folder clips-<user> shared with them (min free 300 GB). Idempotent.
|
||||
POST /mtx-auth -> MediaMTX authHTTP hook: allows read/playback; allows publish iff user/pass carries a valid
|
||||
token whose user owns the path prefix. Tokens are HMAC-signed with /etc/vanguard/stream-token.secret.
|
||||
|
||||
@@ -90,6 +93,96 @@ def do_move(files, night):
|
||||
d.rmdir()
|
||||
return moved, size
|
||||
|
||||
# ------------------------------------------------------------------ Syncthing (self-service clip intake)
|
||||
import urllib.request, urllib.error
|
||||
ST_URL = os.environ.get("SYNCTHING_URL", "http://127.0.0.1:8384")
|
||||
ST_CONFIG = Path(os.environ.get("SYNCTHING_CONFIG", "/srv/media/.syncthing/config.xml"))
|
||||
ST_CLIPS_PREFIX = os.environ.get("SYNCTHING_CLIPS_PREFIX", "/var/syncthing/clips") # path INSIDE the syncthing container
|
||||
STIGNORE_SRC = Path(os.environ.get("STIGNORE_SRC", "/etc/vanguard/stignore"))
|
||||
MIN_FREE_GB = int(os.environ.get("CLIPS_MIN_FREE_GB", "300"))
|
||||
|
||||
def st_apikey():
|
||||
try:
|
||||
m = re.search(r"<apikey>([^<]+)</apikey>", ST_CONFIG.read_text())
|
||||
return m.group(1) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def st(method, path, body=None):
|
||||
key = st_apikey()
|
||||
if not key: raise RuntimeError("syncthing api key not found")
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(ST_URL + path, data=data, method=method, headers={"X-API-Key": key, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
DEVICE_ID_RE = re.compile(r"^[A-Z2-7]{7}(-[A-Z2-7]{7}){7}$")
|
||||
|
||||
def clips_status(user):
|
||||
out = {"user": user, "folder_id": f"clips-{user}", "server_path": f"{ROOT}/clips/{user}"}
|
||||
try:
|
||||
out["server_device_id"] = st("GET", "/rest/system/status")["myID"]
|
||||
cfg_devices = st("GET", "/rest/config/devices")
|
||||
cfg_folders = st("GET", "/rest/config/folders")
|
||||
folder = next((f for f in cfg_folders if f["id"] == out["folder_id"]), None)
|
||||
out["folder_configured"] = folder is not None
|
||||
theirs = [d["deviceID"] for d in (folder or {}).get("devices", []) if d["deviceID"] != out["server_device_id"]]
|
||||
out["device_id"] = theirs[0] if theirs else None
|
||||
if out["device_id"]:
|
||||
conns = st("GET", "/rest/system/connections")["connections"]
|
||||
out["device_connected"] = bool(conns.get(out["device_id"], {}).get("connected"))
|
||||
out["device_name"] = next((d["name"] for d in cfg_devices if d["deviceID"] == out["device_id"]), None)
|
||||
try:
|
||||
comp = st("GET", f"/rest/db/completion?folder={out['folder_id']}&device={out['device_id']}")
|
||||
out["remote_completion"] = comp.get("completion")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stt = st("GET", f"/rest/db/status?folder={out['folder_id']}")
|
||||
out["files_here"] = stt.get("localFiles"); out["state"] = stt.get("state")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
out["error"] = f"syncthing: {e}"
|
||||
out["folder_exists"] = Path(out["server_path"]).is_dir()
|
||||
return out
|
||||
|
||||
def clips_setup(user, device_id, device_name=None):
|
||||
device_id = device_id.strip().upper()
|
||||
if not DEVICE_ID_RE.match(device_id):
|
||||
raise ValueError("that does not look like a Syncthing Device ID (8 groups of 7 characters, e.g. ABCDEFG-HIJKLMN-...)")
|
||||
# 1. server folder with the video-only ignore list
|
||||
d = Path(ROOT) / "clips" / user
|
||||
d.mkdir(parents=True, exist_ok=True); shutil.chown(d, 1000, 1000)
|
||||
ign = d / ".stignore"
|
||||
if not ign.exists() and STIGNORE_SRC.exists():
|
||||
ign.write_text(STIGNORE_SRC.read_text()); shutil.chown(ign, 1000, 1000); ign.chmod(0o644)
|
||||
# 2. their device
|
||||
my_id = st("GET", "/rest/system/status")["myID"]
|
||||
if device_id == my_id: raise ValueError("that is the server's own Device ID — paste yours (your Syncthing → Actions → Show ID)")
|
||||
devices = st("GET", "/rest/config/devices")
|
||||
if not any(x["deviceID"] == device_id for x in devices):
|
||||
st("POST", "/rest/config/devices", {"deviceID": device_id, "name": device_name or user, "addresses": ["dynamic"],
|
||||
"autoAcceptFolders": False, "introducer": False, "paused": False})
|
||||
# 3. receive-only folder shared with them
|
||||
fid = f"clips-{user}"
|
||||
folders = st("GET", "/rest/config/folders")
|
||||
cur = next((f for f in folders if f["id"] == fid), None)
|
||||
spec = {"id": fid, "label": f"Clips — {user}", "path": f"{ST_CLIPS_PREFIX}/{user}", "type": "receiveonly",
|
||||
"devices": [{"deviceID": my_id}, {"deviceID": device_id}],
|
||||
"minDiskFree": {"value": MIN_FREE_GB, "unit": "GB"}, "fsWatcherEnabled": True, "rescanIntervalS": 3600,
|
||||
"ignorePerms": True, "ignoreDelete": False}
|
||||
if cur is None:
|
||||
st("POST", "/rest/config/folders", spec)
|
||||
else:
|
||||
if not any(x["deviceID"] == device_id for x in cur.get("devices", [])):
|
||||
cur["devices"].append({"deviceID": device_id})
|
||||
cur["type"] = "receiveonly"; cur["minDiskFree"] = spec["minDiskFree"]
|
||||
st("PUT", f"/rest/config/folders/{fid}", cur)
|
||||
print(f"{datetime.now(TZ):%F %T} clips setup user={user} device={device_id[:7]}… folder={fid}", flush=True)
|
||||
return clips_status(user)
|
||||
|
||||
def token_secret():
|
||||
try:
|
||||
sec = TOKEN_SECRET_FILE.read_bytes().strip()
|
||||
@@ -164,6 +257,16 @@ class H(BaseHTTPRequestHandler):
|
||||
if self.path.startswith("/time"):
|
||||
# server clock for the timecode strip (the /clock/ page and Go Live measure their offset against this)
|
||||
return self._json(200, {"now_ms": int(_time.time() * 1000)})
|
||||
if self.path == "/me" or self.path == "/me/":
|
||||
who, is_admin = identity(self.headers)
|
||||
if not who: return self._json(401, {"error": "not authenticated"})
|
||||
groups = sorted({g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()})
|
||||
return self._json(200, {"user": who, "groups": groups, "admin": is_admin,
|
||||
"name": self.headers.get("X-Authentik-Name") or who, "email": self.headers.get("X-Authentik-Email") or ""})
|
||||
if self.path.startswith("/me/clips"):
|
||||
who, _ = identity(self.headers)
|
||||
if not who: return self._json(401, {"error": "not authenticated"})
|
||||
return self._json(200, clips_status(who))
|
||||
if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
|
||||
# Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
|
||||
who, _ = identity(self.headers)
|
||||
@@ -217,6 +320,17 @@ class H(BaseHTTPRequestHandler):
|
||||
return self._json(401, {"error": "path must be <your-username>/<Title>"})
|
||||
print(f"{datetime.now(TZ):%F %T} mtx-auth allow publish user={user} path={path} ip={req.get('ip')}", flush=True)
|
||||
return self._json(200, {"ok": True})
|
||||
if self.path.startswith("/me/clips"):
|
||||
who, _ = identity(self.headers)
|
||||
if not who: return self._json(401, {"error": "not authenticated"})
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
return self._json(200, clips_setup(who, str(body.get("device_id", "")), body.get("device_name")))
|
||||
except ValueError as e:
|
||||
return self._json(400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
return self._json(502, {"error": f"syncthing: {e}"})
|
||||
if not self.path.startswith("/dump"):
|
||||
return self._json(404, {"error": "not found"})
|
||||
u, is_admin = identity(self.headers)
|
||||
|
||||
Reference in New Issue
Block a user