Files
playwish/server/app/admin.py
T
99admandClaude Opus 5.5 35985fe66d Admin: eigenen Einladungslink festlegen
Statt des zufälligen Links kann der Admin einen sprechenden Link wählen
(z. B. /z/sommerfest2026, mind. 6 Zeichen: A–Z, a–z, 0–9, - und _).
Alte Zugänge werden dabei ungültig; der QR-Code passt sich an.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:53:37 +01:00

426 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Playwish-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 = "playwish_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 _link_basis(request: Request) -> str:
return (konfig().public_url or str(request.base_url).rstrip("/")) + "/z/"
def _einladungslink(request: Request, token: str) -> str:
return _link_basis(request) + 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"]),
"base": _link_basis(request), "token": 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}
class EigenerLink(BaseModel):
# sprechend, aber nicht zu kurz (Raten wird zusätzlich durch die Einladungs-Drossel gebremst)
token: str = Field(pattern=r"^[A-Za-z0-9_-]{6,64}$")
@api.post("/access/token")
def zugang_eigener_link(z: EigenerLink, db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Eigenen Link festlegen (z. B. /z/sommerfest2026) – alte Zugänge werden ungültig."""
einstellung_setzen(db, "access_token", z.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="playwish-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)