Name, Seitentitel, Dienste (playwish-web, playwish-controller), Dienst-Benutzer (playwish), Pfade (/opt/playwish, /usr/local/src/playwish), Vorlagen und Repository-Adresse. „Audiofy“ war bereits von mehreren Produkten belegt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
18 KiB
Python
410 lines
18 KiB
Python
"""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 _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="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)
|