"""Audiofy-Web – Gastseite: Suche, Wünschen, "Läuft gerade" und Warteschlange. Zugang nur mit Einladung: Der Link /z/ (auch als QR-Code im Admin-Bereich) setzt ein Zugangs-Cookie. Ohne gültigen Zugang gibt es nur eine Hinweisseite, die API antwortet mit 401. Zugang aus oder neuer Link → alle bisherigen Zugänge ungültig. Gäste melden sich nie bei Spotify an. Ein Gerät wird über ein zufälliges Cookie erkannt, zusätzlich über die gehashte IP (nie im Klartext gespeichert). Schutzregeln beim Wünschen: - Wunschannahme pausiert → abgelehnt - Gast/Gerät gesperrt (Cookie-ID oder IP) → abgelehnt - Limit: max. wish_limit_count Wünsche pro wish_limit_minutes (pro Cookie ODER IP) - Titel/Interpret auf der Sperrliste, explizit (falls ausgeschaltet) → abgelehnt - schon in der Warteschlange / zur Freigabe → abgelehnt (keine Duplikate) - lief in den letzten repeat_block_minutes oder läuft gerade → abgelehnt """ import hashlib import hmac import re import secrets import sqlite3 import threading import time from collections import defaultdict, deque from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlsplit 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 einstellungen, get_db, jetzt from .player_api import player_zustand from . import turnstile from .spotify import Spotify, SpotifyFehler, spotify router = APIRouter() STATIC = Path(__file__).parent / "static" GAST_COOKIE = "audiofy_gast" ZUGANG_COOKIE = "audiofy_zugang" ZUGANG_TAGE = 30 GAST_RE = re.compile(r"^[A-Za-z0-9_-]{22}$") PLAYER_OFFLINE_S = 30 # ohne Lebenszeichen gilt der Player als offline MEINE_WUENSCHE_H = 6 # so lange sieht ein Gast seine eigenen Wünsche _wunsch_lock = threading.Lock() # Prüfen + Eintragen atomar (keine Duplikate bei Doppelklick) # --- Hilfen ---------------------------------------------------------------- class Drossel: """Einfache Ratenbegrenzung im Speicher (gleitendes Fenster), pro Schlüssel.""" def __init__(self, anzahl: int, sekunden: int): self.anzahl, self.sekunden = anzahl, sekunden self._zeiten: dict[str, deque] = defaultdict(deque) self._lock = threading.Lock() def erlaubt(self, schluessel: str) -> bool: jetzt_s = time.monotonic() with self._lock: zeiten = self._zeiten[schluessel] while zeiten and jetzt_s - zeiten[0] > self.sekunden: zeiten.popleft() if len(zeiten) >= self.anzahl: return False zeiten.append(jetzt_s) if len(self._zeiten) > 10_000: # Speicher begrenzen self._zeiten = defaultdict(deque, {schluessel: zeiten}) return True suche_drossel = Drossel(30, 60) wunsch_drossel = Drossel(10, 60) einladung_drossel = Drossel(10, 60) # gegen Raten von Einladungslinks def gast_id(request: Request) -> str | None: wert = request.cookies.get(GAST_COOKIE, "") return wert if GAST_RE.match(wert) else None def gast_cookie_setzen(response: Response, gid: str) -> None: response.set_cookie(GAST_COOKIE, gid, max_age=365 * 86400, httponly=True, secure=konfig().cookie_secure, samesite="lax") def _geheimnis() -> bytes: return (konfig().session_secret or "audiofy").encode() def zugangs_wert(token: str) -> str: """Cookie-Wert für den Zugang – hängt am aktuellen Token, ein neuer Link macht alte Cookies ungültig.""" return hmac.new(_geheimnis(), b"zugang:" + token.encode(), hashlib.sha256).hexdigest()[:32] def hat_zugang(request: Request, e: dict) -> bool: if not (e["access_enabled"] and e["access_token"]): return False return hmac.compare_digest(request.cookies.get(ZUGANG_COOKIE, ""), zugangs_wert(e["access_token"])) def zugang_pflicht(request: Request, db: sqlite3.Connection = Depends(get_db)) -> None: """Dependency für die Gast-API.""" if not hat_zugang(request, einstellungen(db)): raise HTTPException(401, "Kein Zugang – bitte den QR-Code vor Ort scannen.") def ip_hash(request: Request) -> str: """IP des Gasts (von Caddy über X-Forwarded-For, Uvicorn --proxy-headers) als HMAC-Hash.""" ip = request.client.host if request.client else "unbekannt" return hmac.new(_geheimnis(), ip.encode(), hashlib.sha256).hexdigest()[:24] def gleiche_herkunft(request: Request) -> None: """CSRF-Schutz für Gast-POSTs: Origin (falls gesendet) muss zur eigenen Seite passen.""" origin = request.headers.get("origin") if origin and urlsplit(origin).netloc != request.headers.get("host"): raise HTTPException(403, "Ungültige Herkunft") def zeitpunkt(iso: str | None) -> datetime | None: return datetime.fromisoformat(iso) if iso else None @dataclass class Kontext: """Vorberechnete Mengen für die Sperrprüfung (eine DB-Runde für alle Suchtreffer).""" aktiv: set[str] # in Warteschlange oder zur Freigabe kuerzlich: set[str] # lief in der Sperrzeit oder läuft gerade titel_gesperrt: set[str] interpreten_gesperrt: set[str] explizit_aus: bool def kontext(db: sqlite3.Connection, e: dict) -> Kontext: grenze = jetzt(-timedelta(minutes=e["repeat_block_minutes"])) kuerzlich = {r[0] for r in db.execute("SELECT track_uri FROM history WHERE started_at >= ?", (grenze,))} aktuell = db.execute("SELECT track_uri FROM pi_state WHERE id = 1").fetchone() if aktuell and aktuell[0]: kuerzlich.add(aktuell[0]) sperre = db.execute("SELECT kind, value FROM blocklist WHERE kind IN ('track', 'artist')").fetchall() return Kontext( aktiv={r[0] for r in db.execute("SELECT track_uri FROM wishes WHERE status IN ('pending', 'queued')")}, kuerzlich=kuerzlich, titel_gesperrt={r["value"] for r in sperre if r["kind"] == "track"}, interpreten_gesperrt={r["value"] for r in sperre if r["kind"] == "artist"}, explizit_aus=bool(e["block_explicit"]), ) def sperrgrund(titel: dict, ktx: Kontext) -> str | None: if titel["track_uri"] in ktx.titel_gesperrt or ktx.interpreten_gesperrt.intersection(titel.get("artist_uris", [])): return "Dieser Titel kann leider nicht gewünscht werden." if ktx.explizit_aus and titel.get("explicit"): return "Titel mit expliziten Inhalten sind ausgeschaltet." if titel["track_uri"] in ktx.aktiv: return "Steht schon auf der Wunschliste." if titel["track_uri"] in ktx.kuerzlich: return "Lief gerade erst – bitte später nochmal." return None def gast_gesperrt(db: sqlite3.Connection, gid: str | None, iph: str) -> bool: return db.execute("SELECT 1 FROM blocklist WHERE (kind = 'guest' AND value = ?) OR (kind = 'ip' AND value = ?)", (gid or "", iph)).fetchone() is not None def limit_status(db: sqlite3.Connection, e: dict, gid: str | None, iph: str) -> tuple[int, int | None]: """(verbleibende Wünsche, Minuten bis zum nächsten möglichen Wunsch oder None).""" grenze = jetzt(-timedelta(minutes=e["wish_limit_minutes"])) zeilen = db.execute("SELECT created_at FROM wishes WHERE (guest_id = ? OR guest_ip = ?) AND created_at >= ? " "ORDER BY created_at", (gid or "-", iph, grenze)).fetchall() rest = max(0, e["wish_limit_count"] - len(zeilen)) if rest: return rest, None frei_ab = zeitpunkt(zeilen[0]["created_at"]) + timedelta(minutes=e["wish_limit_minutes"]) return 0, max(1, round((frei_ab - datetime.now(timezone.utc)).total_seconds() / 60)) # --- Seiten ---------------------------------------------------------------- def kein_zugang_seite(status_code: int = 403) -> FileResponse: return FileResponse(STATIC / "zugang.html", status_code=status_code, headers={"Cache-Control": "no-store"}) @router.get("/", include_in_schema=False) def startseite(request: Request, db: sqlite3.Connection = Depends(get_db)) -> FileResponse: if not hat_zugang(request, einstellungen(db)): return kein_zugang_seite() antwort = FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"}) if not gast_id(request): gast_cookie_setzen(antwort, secrets.token_urlsafe(16)) return antwort @router.get("/z/{token}", include_in_schema=False) def einladung(token: str, request: Request, db: sqlite3.Connection = Depends(get_db)): """Einladungslink bzw. QR-Code: gültig → Zugangs-Cookie setzen und zur Wunschseite.""" if not einladung_drossel.erlaubt(ip_hash(request)): return kein_zugang_seite(429) e = einstellungen(db) if not (e["access_enabled"] and e["access_token"] and len(token) <= 64 and hmac.compare_digest(token.encode(), e["access_token"].encode())): return kein_zugang_seite() antwort = RedirectResponse("/", status_code=303) antwort.set_cookie(ZUGANG_COOKIE, zugangs_wert(e["access_token"]), max_age=ZUGANG_TAGE * 86400, httponly=True, secure=konfig().cookie_secure, samesite="lax") if not gast_id(request): gast_cookie_setzen(antwort, secrets.token_urlsafe(16)) return antwort # --- API ------------------------------------------------------------------- @router.get("/api/search", dependencies=[Depends(zugang_pflicht)]) def suche(request: Request, q: str = Query(min_length=2, max_length=100), db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict: if not suche_drossel.erlaubt(ip_hash(request)): raise HTTPException(429, "Zu viele Suchanfragen – bitte kurz warten.") if not sp.verfuegbar(): raise HTTPException(503, "Die Suche ist gerade nicht verfügbar.") try: treffer = sp.suche(q) except SpotifyFehler: raise HTTPException(503, "Die Suche ist gerade überlastet – bitte gleich nochmal versuchen.") ktx = kontext(db, einstellungen(db)) return {"results": [{k: v for k, v in t.items() if k not in ("artist_uris", "artists")} | {"blocked": sperrgrund(t, ktx)} for t in treffer]} @router.get("/api/turnstile") def turnstile_konfig() -> dict: """Öffentlicher Site-Key für das Turnstile-Widget (null = Schutz aus).""" return {"site_key": konfig().turnstile_site_key if turnstile.aktiv() else None} class WunschAnfrage(BaseModel): track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$") name: str | None = Field(default=None, max_length=30) turnstile: str | None = Field(default=None, max_length=2048) @router.post("/api/wish", dependencies=[Depends(zugang_pflicht)]) def wuenschen(anfrage: WunschAnfrage, request: Request, response: Response, db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict: gleiche_herkunft(request) gid = gast_id(request) if not gid: gid = secrets.token_urlsafe(16) gast_cookie_setzen(response, gid) iph = ip_hash(request) if not wunsch_drossel.erlaubt(iph): raise HTTPException(429, "Zu viele Versuche – bitte kurz warten.") e = einstellungen(db) if e["wishes_paused"]: raise HTTPException(403, "Die Wunschannahme ist gerade pausiert.") if gast_gesperrt(db, gid, iph): raise HTTPException(403, "Von diesem Gerät sind gerade keine Wünsche möglich.") rest, minuten = limit_status(db, e, gid, iph) if not rest: raise HTTPException(429, f"Du hast dein Limit von {e['wish_limit_count']} Wünschen erreicht – " f"nächster Wunsch in ca. {minuten} Min.") turnstile.pruefen(anfrage.turnstile, request, "wish", bei_ausfall_erlauben=True) try: titel = sp.titel(anfrage.track_uri) # Titelinfos immer selbst holen, nie dem Browser glauben except SpotifyFehler as fehler: if fehler.status in (400, 404): raise HTTPException(404, "Titel nicht gefunden.") raise HTTPException(503, "Spotify ist gerade nicht erreichbar – bitte gleich nochmal versuchen.") name = " ".join((anfrage.name or "").split()) or None status = "pending" if e["moderation"] else "queued" with _wunsch_lock: grund = sperrgrund(titel, kontext(db, e)) if grund: raise HTTPException(409, grund) 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_id, guest_ip, guest_name, status, position, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (titel["track_uri"], titel["title"], titel["artist"], titel["album"], titel["cover_url"], titel["duration_ms"], int(titel["explicit"]), gid, iph, name, status, pos, jetzt())) return {"ok": True, "status": status, "message": "Dein Wunsch wartet auf Freigabe." if status == "pending" else "Dein Wunsch ist auf der Liste!"} @router.get("/api/status", dependencies=[Depends(zugang_pflicht)]) def status(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict: gid = gast_id(request) e = einstellungen(db) pi = player_zustand(db) zustand = pi["state"] zuletzt = zeitpunkt(pi["last_seen"]) online = bool(zuletzt and (datetime.now(timezone.utc) - zuletzt).total_seconds() < PLAYER_OFFLINE_S) jetzt_laeuft = None if online and zustand.get("track"): alter_ms = int((datetime.now(timezone.utc) - zeitpunkt(pi["updated_at"])).total_seconds() * 1000) jetzt_laeuft = {**{k: zustand["track"].get(k) for k in ("title", "artist", "cover_url", "duration_ms")}, "progress_ms": zustand.get("progress_ms", 0), "is_playing": zustand.get("is_playing", False), "age_ms": max(0, alter_ms), "is_wish": zustand.get("source") == "wish"} warteschlange = [{"title": r["title"], "artist": r["artist"], "cover_url": r["cover_url"], "name": r["guest_name"], "mine": bool(gid) and r["guest_id"] == gid} for r in db.execute("SELECT * FROM wishes WHERE status = 'queued' AND priority = 0 " "ORDER BY position, id LIMIT 30")] meine = [] if gid: grenze = jetzt(-timedelta(hours=MEINE_WUENSCHE_H)) meine = [{"title": r["title"], "artist": r["artist"], "status": r["status"]} for r in db.execute("SELECT title, artist, status FROM wishes WHERE guest_id = ? AND created_at >= ? " "ORDER BY created_at DESC LIMIT 10", (gid, grenze))] rest, minuten = limit_status(db, e, gid, ip_hash(request)) return { "now_playing": jetzt_laeuft, "online": online, "queue": warteschlange, "pending_count": db.execute("SELECT COUNT(*) FROM wishes WHERE status = 'pending'").fetchone()[0], "mine": meine, "wishes_paused": e["wishes_paused"], "moderation": e["moderation"], "wish_every_n": e["wish_every_n"], "limit": {"count": e["wish_limit_count"], "minutes": e["wish_limit_minutes"], "remaining": rest, "next_in_minutes": minuten}, }