"""Audiofy-Web – Admin-Bereich (/admin): der Besitzer steuert alles, auch vom Handy. Zugang: - Passwort als argon2-Hash in der .env (ADMIN_PASSWORD_HASH, gesetzt per verwaltung.py) - Login-Versuche: max. 5 pro 15 min pro IP - Sitzungs-Cookie HttpOnly, Secure, SameSite=Strict; in der DB nur als SHA-256-Hash - jede ändernde Aktion braucht den CSRF-Token der Sitzung (Header X-CSRF-Token) Befehle an den Player (play, pause, skip, volume, bt_scan, bt_connect) landen in der Tabelle commands und werden vom Controller mit /api/player/config abgeholt. """ import hashlib import hmac import io import re import secrets import sqlite3 from datetime import datetime, timedelta, timezone from typing import Literal import segno from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, RedirectResponse from pydantic import BaseModel, Field from .config import konfig from .db import (BEFEHL_GUELTIG_S, befehl_anlegen, einstellung_setzen, einstellungen, get_db, jetzt, neuer_zugangs_token) from .gaeste import (PLAYER_OFFLINE_S, STATIC, Drossel, gleiche_herkunft, ip_hash, zeitpunkt) from .player_api import player_zustand from . import turnstile from .spotify import Spotify, SpotifyFehler, spotify router = APIRouter() ADMIN_COOKIE = "audiofy_admin" SITZUNG_TAGE = 14 login_drossel = Drossel(5, 15 * 60) _hasher = PasswordHasher() PLAYLIST_RE = re.compile(r"(?:playlist[/:])([A-Za-z0-9]{22})|^([A-Za-z0-9]{22})$") # Einstellungen, die direkt per /admin/api/settings geändert werden dürfen (Typ, Min, Max) AENDERBAR = { "wish_every_n": (int, 1, 20), "wish_limit_count": (int, 1, 50), "wish_limit_minutes": (int, 1, 1440), "repeat_block_minutes": (int, 0, 1440), "playlist_repeat_percent": (int, 0, 100), "moderation": (bool, None, None), "wishes_paused": (bool, None, None), "block_explicit": (bool, None, None), } # --- Sitzung --------------------------------------------------------------- def _hash(wert: str) -> str: return hashlib.sha256(wert.encode()).hexdigest() def sitzung(request: Request, db: sqlite3.Connection) -> sqlite3.Row | None: wert = request.cookies.get(ADMIN_COOKIE) if not wert or len(wert) > 100: return None return db.execute("SELECT * FROM admin_sessions WHERE id = ? AND expires_at > ?", (_hash(wert), jetzt())).fetchone() def admin_pflicht(request: Request, db: sqlite3.Connection = Depends(get_db)) -> sqlite3.Row: s = sitzung(request, db) if s is None: raise HTTPException(401, "Bitte anmelden.") if request.method not in ("GET", "HEAD"): gleiche_herkunft(request) if not hmac.compare_digest(request.headers.get("x-csrf-token", ""), s["csrf"]): raise HTTPException(403, "Sicherheitsprüfung fehlgeschlagen – Seite neu laden.") return s api = APIRouter(prefix="/admin/api", dependencies=[Depends(admin_pflicht)]) # --- Seiten & Login -------------------------------------------------------- def _seite(datei: str) -> FileResponse: return FileResponse(STATIC / datei, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"}) @router.get("/admin", include_in_schema=False) def admin_seite(request: Request, db: sqlite3.Connection = Depends(get_db)): if sitzung(request, db) is None: return RedirectResponse("/admin/login", status_code=303) return _seite("admin.html") @router.get("/admin/login", include_in_schema=False) def login_seite(request: Request, db: sqlite3.Connection = Depends(get_db)): if sitzung(request, db) is not None: return RedirectResponse("/admin", status_code=303) return _seite("login.html") class Login(BaseModel): password: str = Field(min_length=1, max_length=200) turnstile: str | None = Field(default=None, max_length=2048) @router.post("/admin/login", include_in_schema=False) def login(daten: Login, request: Request, response: Response, db: sqlite3.Connection = Depends(get_db)) -> dict: gleiche_herkunft(request) if not login_drossel.erlaubt(ip_hash(request)): raise HTTPException(429, "Zu viele Anmeldeversuche – bitte 15 Minuten warten.") gespeichert = konfig().admin_passwort_hash if not gespeichert: raise HTTPException(503, "Es ist noch kein Admin-Passwort gesetzt.") turnstile.pruefen(daten.turnstile, request, "login", bei_ausfall_erlauben=False) try: _hasher.verify(gespeichert, daten.password) except (VerificationError, InvalidHashError): raise HTTPException(401, "Falsches Passwort.") wert = secrets.token_urlsafe(32) ablauf = jetzt(timedelta(days=SITZUNG_TAGE)) with db: db.execute("DELETE FROM admin_sessions WHERE expires_at <= ?", (jetzt(),)) db.execute("INSERT INTO admin_sessions (id, csrf, created_at, expires_at) VALUES (?, ?, ?, ?)", (_hash(wert), secrets.token_urlsafe(24), jetzt(), ablauf)) response.set_cookie(ADMIN_COOKIE, wert, max_age=SITZUNG_TAGE * 86400, httponly=True, secure=konfig().cookie_secure, samesite="strict", path="/") return {"ok": True} @api.post("/logout") def logout(request: Request, response: Response, db: sqlite3.Connection = Depends(get_db)) -> dict: with db: db.execute("DELETE FROM admin_sessions WHERE id = ?", (_hash(request.cookies.get(ADMIN_COOKIE, "")),)) response.delete_cookie(ADMIN_COOKIE, path="/") return {"ok": True} # --- Gesamtzustand ----------------------------------------------------------- def _wunsch(r: sqlite3.Row) -> dict: return {k: r[k] for k in ("id", "track_uri", "title", "artist", "cover_url", "guest_name", "status", "priority", "explicit", "created_at")} | {"has_guest": bool(r["guest_id"] or r["guest_ip"])} @api.get("/state") def zustand(s: sqlite3.Row = Depends(admin_pflicht), db: sqlite3.Connection = Depends(get_db)) -> dict: e = einstellungen(db) pi = player_zustand(db) zuletzt = zeitpunkt(pi["last_seen"]) alter_s = (datetime.now(timezone.utc) - zuletzt).total_seconds() if zuletzt else None gemeldet = zeitpunkt(pi["updated_at"]) grenze = jetzt(-timedelta(seconds=BEFEHL_GUELTIG_S)) befehle = [{"id": r["id"], "type": r["type"], "created_at": r["created_at"], "status": ("ok" if r["ok"] else "fehler") if r["acked_at"] else ("wartet" if r["created_at"] >= grenze else "abgelaufen"), "message": r["message"]} for r in db.execute("SELECT * FROM commands ORDER BY id DESC LIMIT 8")] return { "csrf": s["csrf"], "player": { "online": alter_s is not None and alter_s < PLAYER_OFFLINE_S, "last_seen_s": None if alter_s is None else int(alter_s), "state": pi["state"], "bluetooth": pi["bluetooth"], "age_ms": int((datetime.now(timezone.utc) - gemeldet).total_seconds() * 1000) if gemeldet else None, }, "settings": {k: v for k, v in e.items() if k != "access_token"}, "queue": [_wunsch(r) for r in db.execute("SELECT * FROM wishes WHERE status = 'queued' " "ORDER BY priority DESC, position, id")], "pending": [_wunsch(r) for r in db.execute("SELECT * FROM wishes WHERE status = 'pending' " "ORDER BY position, id")], "commands": befehle, } # --- Wiedergabe -------------------------------------------------------------- class Befehl(BaseModel): type: Literal["play", "pause", "skip", "volume", "bt_scan", "bt_connect"] percent: int | None = Field(default=None, ge=0, le=100) mac: str | None = Field(default=None, pattern=r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$") @api.post("/command") def befehl(b: Befehl, db: sqlite3.Connection = Depends(get_db)) -> dict: if b.type == "volume" and b.percent is None: raise HTTPException(422, "Lautstärke fehlt.") if b.type == "bt_connect" and not b.mac: raise HTTPException(422, "Lautsprecher fehlt.") payload = {"percent": b.percent} if b.type == "volume" else {"mac": b.mac} if b.type == "bt_connect" else {} nr = befehl_anlegen(db, b.type, payload) return {"ok": True, "id": nr} class NotAus(BaseModel): stop: bool @api.post("/emergency") def not_aus(n: NotAus, db: sqlite3.Connection = Depends(get_db)) -> dict: """Not-Aus: Wiedergabe stoppen und Controller anhalten, bis wieder freigegeben.""" einstellung_setzen(db, "emergency_stop", n.stop) befehl_anlegen(db, "pause" if n.stop else "play") return {"ok": True} @api.get("/search") def suche(q: str = Query(min_length=2, max_length=100), sp: Spotify = Depends(spotify)) -> dict: if not sp.verfuegbar(): raise HTTPException(503, "Spotify-Suche ist nicht eingerichtet.") try: return {"results": sp.suche(q)} except SpotifyFehler: raise HTTPException(503, "Spotify ist gerade nicht erreichbar.") class TitelWahl(BaseModel): track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$") @api.post("/play-next") def als_naechstes(t: TitelWahl, db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict: """Besitzer-Priorität: vor alle Wünsche, ohne Limits und Sperren.""" try: titel = sp.titel(t.track_uri) except SpotifyFehler: raise HTTPException(503, "Titel konnte nicht geladen werden.") with db: pos = db.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM wishes").fetchone()[0] db.execute("INSERT INTO wishes (track_uri, title, artist, album, cover_url, duration_ms, explicit, " "guest_name, status, priority, position, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, 'Besitzer', 'queued', 1, ?, ?)", (titel["track_uri"], titel["title"], titel["artist"], titel["album"], titel["cover_url"], titel["duration_ms"], int(titel["explicit"]), pos, jetzt())) return {"ok": True} # --- Wünsche ----------------------------------------------------------------- @api.post("/wish/{wish_id}/{aktion}") def wunsch_aktion(wish_id: int, aktion: Literal["approve", "reject", "delete", "up", "down", "block-guest"], db: sqlite3.Connection = Depends(get_db)) -> dict: w = db.execute("SELECT * FROM wishes WHERE id = ?", (wish_id,)).fetchone() if w is None: raise HTTPException(404, "Wunsch nicht gefunden.") with db: if aktion == "approve": db.execute("UPDATE wishes SET status = 'queued' WHERE id = ? AND status = 'pending'", (wish_id,)) elif aktion == "reject": db.execute("UPDATE wishes SET status = 'rejected' WHERE id = ? AND status = 'pending'", (wish_id,)) elif aktion == "delete": db.execute("UPDATE wishes SET status = 'deleted' WHERE id = ? AND status IN ('pending', 'queued')", (wish_id,)) elif aktion in ("up", "down"): # mit dem Nachbarn gleicher Liste (Status + Priorität) die Position tauschen vergleich, ordnung = ("<", "DESC") if aktion == "up" else (">", "ASC") n = db.execute(f"SELECT id, position FROM wishes WHERE status = ? AND priority = ? " f"AND (position {vergleich} ? OR (position = ? AND id {vergleich} ?)) " f"ORDER BY position {ordnung}, id {ordnung} LIMIT 1", (w["status"], w["priority"], w["position"], w["position"], wish_id)).fetchone() if n: if n["position"] == w["position"]: # gleiche Position → eindeutig machen db.execute("UPDATE wishes SET position = position + 0.5 WHERE id = ?", (wish_id if aktion == "down" else n["id"],)) else: db.execute("UPDATE wishes SET position = ? WHERE id = ?", (n["position"], wish_id)) db.execute("UPDATE wishes SET position = ? WHERE id = ?", (w["position"], n["id"])) elif aktion == "block-guest": label = w["guest_name"] or f"Gast von „{w['title']}“" for art, wert in (("guest", w["guest_id"]), ("ip", w["guest_ip"])): if wert: db.execute("INSERT OR IGNORE INTO blocklist (kind, value, label, created_at) VALUES (?, ?, ?, ?)", (art, wert, label, jetzt())) # offene Wünsche dieses Gasts gleich mit entfernen db.execute("UPDATE wishes SET status = 'deleted' WHERE status IN ('pending', 'queued') " "AND ((guest_id IS NOT NULL AND guest_id = ?) OR (guest_ip IS NOT NULL AND guest_ip = ?))", (w["guest_id"], w["guest_ip"])) return {"ok": True} # --- Sperrliste ---------------------------------------------------------------- class Sperre(BaseModel): kind: Literal["track", "artist"] value: str = Field(pattern=r"^spotify:(track|artist):[A-Za-z0-9]{22}$") label: str = Field(max_length=300) @api.get("/blocklist") def sperrliste(db: sqlite3.Connection = Depends(get_db)) -> dict: return {"items": [{"id": r["id"], "kind": r["kind"], "label": r["label"], "created_at": r["created_at"]} for r in db.execute("SELECT * FROM blocklist ORDER BY kind, id DESC")]} @api.post("/blocklist") def sperren(sp: Sperre, db: sqlite3.Connection = Depends(get_db)) -> dict: if not sp.value.startswith(f"spotify:{sp.kind}:"): raise HTTPException(422, "Art und Wert passen nicht zusammen.") with db: db.execute("INSERT OR IGNORE INTO blocklist (kind, value, label, created_at) VALUES (?, ?, ?, ?)", (sp.kind, sp.value, sp.label, jetzt())) return {"ok": True} @api.post("/blocklist/{eintrag_id}/delete") def entsperren(eintrag_id: int, db: sqlite3.Connection = Depends(get_db)) -> dict: with db: db.execute("DELETE FROM blocklist WHERE id = ?", (eintrag_id,)) return {"ok": True} # --- Einstellungen ------------------------------------------------------------- @api.post("/settings") def einstellungen_aendern(werte: dict, db: sqlite3.Connection = Depends(get_db)) -> dict: for key, wert in werte.items(): if key not in AENDERBAR: raise HTTPException(422, f"Unbekannte Einstellung: {key}") typ, minimum, maximum = AENDERBAR[key] if typ is bool and not isinstance(wert, bool): raise HTTPException(422, f"{key}: ja/nein erwartet") if typ is int and (isinstance(wert, bool) or not isinstance(wert, int) or not minimum <= wert <= maximum): raise HTTPException(422, f"{key}: Zahl von {minimum} bis {maximum} erwartet") for key, wert in werte.items(): einstellung_setzen(db, key, wert) return {"ok": True} class PlaylistLink(BaseModel): link: str = Field(max_length=300) @api.post("/playlist") def playlist_wechseln(p: PlaylistLink, db: sqlite3.Connection = Depends(get_db)) -> dict: treffer = PLAYLIST_RE.search(p.link.strip()) if not treffer: raise HTTPException(422, "Das sieht nicht nach einem Spotify-Playlist-Link aus.") playlist_id = treffer.group(1) or treffer.group(2) einstellung_setzen(db, "playlist_id", playlist_id) einstellung_setzen(db, "reshuffle_token", einstellungen(db)["reshuffle_token"] + 1) return {"ok": True, "playlist_id": playlist_id} @api.post("/reshuffle") def neu_mischen(db: sqlite3.Connection = Depends(get_db)) -> dict: einstellung_setzen(db, "reshuffle_token", einstellungen(db)["reshuffle_token"] + 1) return {"ok": True} # --- Gastzugang (Einladungslink / QR-Code) --------------------------------------- def _einladungslink(request: Request, token: str) -> str: basis = konfig().public_url or str(request.base_url).rstrip("/") return f"{basis}/z/{token}" @api.get("/access") def zugang(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict: e = einstellungen(db) return {"enabled": e["access_enabled"], "link": _einladungslink(request, e["access_token"])} class ZugangSchalter(BaseModel): enabled: bool @api.post("/access") def zugang_schalten(z: ZugangSchalter, db: sqlite3.Connection = Depends(get_db)) -> dict: einstellung_setzen(db, "access_enabled", z.enabled) return {"ok": True} @api.post("/access/renew") def zugang_erneuern(db: sqlite3.Connection = Depends(get_db)) -> dict: """Neuer Link: der alte Link und alle damit erteilten Zugänge werden ungültig.""" einstellung_setzen(db, "access_token", neuer_zugangs_token()) return {"ok": True} @api.get("/access/qr") def zugang_qr(request: Request, format: Literal["svg", "png"] = "svg", db: sqlite3.Connection = Depends(get_db)) -> Response: qr = segno.make(_einladungslink(request, einstellungen(db)["access_token"]), error="m") puffer = io.BytesIO() if format == "png": qr.save(puffer, kind="png", scale=16, border=3) return Response(puffer.getvalue(), media_type="image/png", headers={ "Content-Disposition": 'attachment; filename="audiofy-einladung.png"', "Cache-Control": "no-store"}) qr.save(puffer, kind="svg", scale=8, border=2, xmldecl=False, svgns=True) return Response(puffer.getvalue(), media_type="image/svg+xml", headers={"Cache-Control": "no-store"}) # --- Verlauf ------------------------------------------------------------------- @api.get("/history") def verlauf(limit: int = Query(default=100, ge=1, le=500), db: sqlite3.Connection = Depends(get_db)) -> dict: zeilen = db.execute("SELECT h.*, w.guest_name FROM history h LEFT JOIN wishes w ON w.id = h.wish_id " "ORDER BY h.id DESC LIMIT ?", (limit,)) return {"items": [{"title": r["title"], "artist": r["artist"], "cover_url": r["cover_url"], "source": r["source"], "guest_name": r["guest_name"], "started_at": r["started_at"]} for r in zeilen]} router.include_router(api)