From d56be7b2c028a267e644de4a78623048f423f8a7 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 5 Aug 2026 16:07:21 +0200 Subject: [PATCH] =?UTF-8?q?Wichtel-Werkstatt:=20Secret-Santa-App=20mit=20R?= =?UTF-8?q?=C3=A4umen,=20Ausschl=C3=BCssen=20und=20Recovery-Links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI + SQLite, serverseitig gerenderte Templates, mobil-zuerst - Admin-Passwort, einseitige Ausschlüsse, eingefrorene Auslosung - Teilnehmer sehen Ergebnis per Cookie; Einmal-Recovery-Links bei Verlust - Docker/podman-tauglich (Entrypoint mit Privilegien-Drop, SELinux-:z) - Unit-Tests für Auslosung, E2E-Testskript (30 Checks) --- .dockerignore | 6 + .gitignore | 3 + Dockerfile | 20 ++ README.md | 74 +++++ app/db.py | 285 +++++++++++++++++++ app/draw.py | 41 +++ app/main.py | 496 +++++++++++++++++++++++++++++++++ app/static/app.js | 37 +++ app/static/style.css | 281 +++++++++++++++++++ app/templates/admin.html | 167 +++++++++++ app/templates/admin_login.html | 17 ++ app/templates/base.html | 20 ++ app/templates/home.html | 42 +++ app/templates/message.html | 9 + app/templates/room.html | 52 ++++ docker-compose.yml | 13 + entrypoint.sh | 15 + nginx.example.conf | 28 ++ requirements.txt | 4 + tests/e2e.sh | 144 ++++++++++ tests/test_draw.py | 85 ++++++ 21 files changed, 1839 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/db.py create mode 100644 app/draw.py create mode 100644 app/main.py create mode 100644 app/static/app.js create mode 100644 app/static/style.css create mode 100644 app/templates/admin.html create mode 100644 app/templates/admin_login.html create mode 100644 app/templates/base.html create mode 100644 app/templates/home.html create mode 100644 app/templates/message.html create mode 100644 app/templates/room.html create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 nginx.example.conf create mode 100644 requirements.txt create mode 100755 tests/e2e.sh create mode 100644 tests/test_draw.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b77af65 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +data/ +__pycache__/ +*.pyc +.git/ +tests/ +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac481ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58e1af6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY entrypoint.sh ./entrypoint.sh + +RUN chmod +x /app/entrypoint.sh \ + && useradd --create-home wichtel \ + && mkdir -p /data \ + && chown wichtel:wichtel /data + +ENV WICHTELN_DATA_DIR=/data + +EXPOSE 8000 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5782dc7 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Wichtel-Werkstatt 🎅 + +Wichteln ohne Zettel-Chaos: Ein Admin erstellt einen Raum, alle melden sich per +Link an, Ausschlüsse werden festgelegt (z. B. Ehepartner), dann wird fair +ausgelost. Jede Person sieht **nur ihr eigenes Ergebnis** – der Admin sieht +keine Zuweisungen. + +## So funktioniert's + +1. **Raum erstellen:** Auf der Startseite Raumname + Admin-Passwort angeben. + Es gibt einen Einladungslink und einen 6-stelligen Code zum Weitergeben. +2. **Anmelden:** Alle Teilnehmer öffnen den Link und geben ihren Namen ein. + Der Browser merkt sich die Teilnahme über ein langlebiges Cookie. + Der Admin nimmt über „Selbst mitwichteln" im Panel direkt selbst teil. +3. **Ausschlüsse:** Der Admin legt einseitig fest: „A darf nicht B ziehen“. + (Ehepaare = zwei Einträge, je einer pro Richtung.) +4. **Auslosen:** Ab 3 Teilnehmern. Danach ist der Raum eingefroren – + nichts kann mehr geändert werden. +5. **Ergebnis:** Jeder Teilnehmer sieht auf seiner Seite, wen er beschenkt. + Der Admin sieht nur, *dass* ausgelost wurde – nicht, wer wen gezogen hat. + +**Cookie verloren?** Der Admin kann im Panel jederzeit (auch nach der Auslosung) +einen Einmal-Wiederherstellungslink für eine Person erstellen. Damit bekommt +sie auf einem beliebigen Gerät ihr Ergebnis zurück. Der Link funktioniert +genau einmal und verrät dem Admin weiterhin nichts über die Zuweisungen. + +## Technik + +- Python/FastAPI, serverseitig gerenderte Templates (Jinja2), kein Build-Schritt +- SQLite-Datei in einem Volume – dieses Verzeichnis zu sichern ist das komplette Backup +- Mobil-zuerst gestaltet, läuft ohne externe CDN/Font-Abhängigkeiten + +## Lokal testen (Bazzite/podman) + +```bash +podman build -t wichteln . +podman volume create wichtel-data # überlebt Container-Neustarts +podman run -d --name wichteln -p 127.0.0.1:8080:8000 \ + -v wichtel-data:/data wichteln +``` + +Dann öffnen. Stoppen/aufräumen: + +```bash +podman rm -f wichteln +# optional: podman volume rm wichtel-data +``` + +Alternativ mit Bind-Mount statt Volume (auf SELinux-Systemen wie Bazzite/Fedora +ist `:z` für das SELinux-Label nötig): + +```bash +podman run -d --name wichteln -p 127.0.0.1:8080:8000 -v "$PWD/data:/data:z" localhost/wichteln +``` + +Die Compose-Datei funktioniert sowohl mit `docker compose` (Server) als auch +mit `podman-compose up -d` lokal. + +## Auf dem Server (docker compose + nginx) + +```bash +docker compose up -d --build +``` + +Der Container lauscht nur auf `127.0.0.1:8000`; davor gehört ein Reverse +Proxy – siehe [`nginx.example.conf`](nginx.example.conf). **HTTPS wird dringend +empfohlen**, damit Ergebnis-Cookies nicht unverschlüsselt übertragen werden. +Die Daten liegen im `./data/`-Verzeichnis neben der Compose-Datei. + +## Tests + +```bash +python3 tests/test_draw.py # Auslosungslogik, ohne Abhängigkeiten +``` diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..26cba90 --- /dev/null +++ b/app/db.py @@ -0,0 +1,285 @@ +"""SQLite-Zugriff für die Wichtel-Werkstatt. + +Eine Datei, ein Volume: Die komplette Anwendung lebt in einer SQLite-Datei +unter $WICHTELN_DATA_DIR (Default: ./data). Pro Request wird eine eigene +Verbindung geöffnet – bei der erwarteten Last (Familien/Freunde) ist das +robuster als eine geteilte Verbindung. +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +DB_DIR = os.environ.get("WICHTELN_DATA_DIR", "./data") +DB_PATH = os.path.join(DB_DIR, "wichteln.db") + +# 6-stelliger Raumcode ohne mehrdeutige Zeichen (kein 0/O, 1/I/L). +CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +CODE_LENGTH = 6 + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + admin_token_hash TEXT, + created_at TEXT NOT NULL, + drawn_at TEXT +); + +CREATE TABLE IF NOT EXISTS participants ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + name TEXT NOT NULL COLLATE NOCASE, + token_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (room_id, name) +); + +CREATE TABLE IF NOT EXISTS exclusions ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + from_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + to_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + PRIMARY KEY (room_id, from_id, to_id) +); + +CREATE TABLE IF NOT EXISTS assignments ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + giver_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + receiver_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + PRIMARY KEY (room_id, giver_id), + UNIQUE (room_id, receiver_id) +); + +CREATE TABLE IF NOT EXISTS recovery_links ( + token_hash TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + used_at TEXT +); +""" + + +def connect() -> sqlite3.Connection: + os.makedirs(DB_DIR, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def init_db() -> None: + with connect() as conn: + conn.executescript(_SCHEMA) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def new_id() -> str: + return secrets.token_urlsafe(12) + + +def new_token() -> str: + return secrets.token_urlsafe(24) + + +def hash_token(token: str) -> str: + """SHA-256 reicht für hochentropische Zufalls-Tokens.""" + return hashlib.sha256(token.encode()).hexdigest() + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 390_000) + return f"{salt.hex()}${dk.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + salt_hex, dk_hex = stored.split("$", 1) + except ValueError: + return False + salt = bytes.fromhex(salt_hex) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 390_000) + return hmac.compare_digest(dk.hex(), dk_hex) + + +def new_room_code(conn: sqlite3.Connection) -> str: + while True: + code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) + row = conn.execute("SELECT 1 FROM rooms WHERE code = ?", (code,)).fetchone() + if row is None: + return code + + +# ---------- Räume ---------- + +def create_room(conn: sqlite3.Connection, name: str, password: str) -> sqlite3.Row: + room_id = new_id() + code = new_room_code(conn) + conn.execute( + "INSERT INTO rooms (id, code, name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + (room_id, code, name.strip(), hash_password(password), now_iso()), + ) + return get_room_by_id(conn, room_id) + + +def get_room_by_id(conn: sqlite3.Connection, room_id: str) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone() + + +def get_room_by_code(conn: sqlite3.Connection, code: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM rooms WHERE code = ?", (code.strip().upper(),) + ).fetchone() + + +def set_admin_token(conn: sqlite3.Connection, room_id: str, token: str) -> None: + conn.execute( + "UPDATE rooms SET admin_token_hash = ? WHERE id = ?", + (hash_token(token), room_id), + ) + + +def clear_admin_token(conn: sqlite3.Connection, room_id: str) -> None: + conn.execute("UPDATE rooms SET admin_token_hash = NULL WHERE id = ?", (room_id,)) + + +def room_is_drawn(room: sqlite3.Row) -> bool: + return room["drawn_at"] is not None + + +# ---------- Teilnehmer ---------- + +def add_participant(conn: sqlite3.Connection, room_id: str, name: str, token: str) -> sqlite3.Row: + pid = new_id() + conn.execute( + "INSERT INTO participants (id, room_id, name, token_hash, created_at) VALUES (?, ?, ?, ?, ?)", + (pid, room_id, name.strip(), hash_token(token), now_iso()), + ) + return get_participant(conn, pid) + + +def get_participant(conn: sqlite3.Connection, participant_id: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM participants WHERE id = ?", (participant_id,) + ).fetchone() + + +def set_participant_token(conn: sqlite3.Connection, participant_id: str, token: str) -> None: + """Neues Token setzen, z. B. wenn ein Cookie verloren ging (Recovery-Link).""" + conn.execute( + "UPDATE participants SET token_hash = ? WHERE id = ?", + (hash_token(token), participant_id), + ) + + +def get_participant_by_token(conn: sqlite3.Connection, room_id: str, token: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM participants WHERE room_id = ? AND token_hash = ?", + (room_id, hash_token(token)), + ).fetchone() + + +def list_participants(conn: sqlite3.Connection, room_id: str) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM participants WHERE room_id = ? ORDER BY created_at", + (room_id,), + ).fetchall() + + +def delete_participant(conn: sqlite3.Connection, room_id: str, participant_id: str) -> None: + conn.execute( + "DELETE FROM participants WHERE id = ? AND room_id = ?", + (participant_id, room_id), + ) + + +# ---------- Ausschlüsse ---------- + +def add_exclusion(conn: sqlite3.Connection, room_id: str, from_id: str, to_id: str) -> None: + conn.execute( + "INSERT OR IGNORE INTO exclusions (room_id, from_id, to_id) VALUES (?, ?, ?)", + (room_id, from_id, to_id), + ) + + +def remove_exclusion(conn: sqlite3.Connection, room_id: str, from_id: str, to_id: str) -> None: + conn.execute( + "DELETE FROM exclusions WHERE room_id = ? AND from_id = ? AND to_id = ?", + (room_id, from_id, to_id), + ) + + +def list_exclusions(conn: sqlite3.Connection, room_id: str) -> list[sqlite3.Row]: + return conn.execute( + """SELECT e.from_id, e.to_id, pf.name AS from_name, pt.name AS to_name + FROM exclusions e + JOIN participants pf ON pf.id = e.from_id + JOIN participants pt ON pt.id = e.to_id + WHERE e.room_id = ? + ORDER BY pf.name, pt.name""", + (room_id,), + ).fetchall() + + +def exclusion_pairs(conn: sqlite3.Connection, room_id: str) -> set[tuple[str, str]]: + rows = conn.execute( + "SELECT from_id, to_id FROM exclusions WHERE room_id = ?", (room_id,) + ).fetchall() + return {(r["from_id"], r["to_id"]) for r in rows} + + +# ---------- Auslosung ---------- + +def save_assignments(conn: sqlite3.Connection, room_id: str, mapping: dict[str, str]) -> None: + conn.executemany( + "INSERT INTO assignments (room_id, giver_id, receiver_id) VALUES (?, ?, ?)", + [(room_id, giver, receiver) for giver, receiver in mapping.items()], + ) + conn.execute("UPDATE rooms SET drawn_at = ? WHERE id = ?", (now_iso(), room_id)) + + +def get_assignment_for(conn: sqlite3.Connection, room_id: str, giver_id: str) -> sqlite3.Row | None: + return conn.execute( + """SELECT a.receiver_id, p.name AS receiver_name + FROM assignments a JOIN participants p ON p.id = a.receiver_id + WHERE a.room_id = ? AND a.giver_id = ?""", + (room_id, giver_id), + ).fetchone() + + +# ---------- Recovery-Links ---------- + +def create_recovery_link(conn: sqlite3.Connection, room_id: str, participant_id: str, token: str) -> None: + conn.execute( + "DELETE FROM recovery_links WHERE participant_id = ? AND used_at IS NULL", + (participant_id,), + ) + conn.execute( + "INSERT INTO recovery_links (token_hash, room_id, participant_id, created_at) VALUES (?, ?, ?, ?)", + (hash_token(token), room_id, participant_id, now_iso()), + ) + + +def get_recovery_link(conn: sqlite3.Connection, token: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM recovery_links WHERE token_hash = ?", (hash_token(token),) + ).fetchone() + + +def mark_recovery_link_used(conn: sqlite3.Connection, token_hash: str) -> None: + conn.execute( + "UPDATE recovery_links SET used_at = ? WHERE token_hash = ?", + (now_iso(), token_hash), + ) diff --git a/app/draw.py b/app/draw.py new file mode 100644 index 0000000..fd456f3 --- /dev/null +++ b/app/draw.py @@ -0,0 +1,41 @@ +"""Auslosung: zufällige Zuordnung, bei der niemand sich selbst zieht +und einseitige Ausschlüsse respektiert werden. + +Ansatz: Rejection-Sampling über zufällige Permutationen. Für typische +Gruppen (Familien/Freunde, < 30 Personen) findet sich eine gültige +Permutation fast immer in wenigen Versuchen (nur die Derangement- +Bedingung allein hat bereits Erfolgswahrscheinlichkeit ~1/e). +""" + +from __future__ import annotations + +import random + +MAX_ATTEMPTS = 2000 + + +def draw_assignment( + participant_ids: list[str], + exclusions: set[tuple[str, str]], + *, + rng: random.Random | None = None, + max_attempts: int = MAX_ATTEMPTS, +) -> dict[str, str] | None: + """Liefert {giver_id: receiver_id} oder None, wenn keine gültige + Zuordnung gefunden wurde (z. B. unerfüllbare Ausschlüsse).""" + rng = rng or random.Random() + ids = list(participant_ids) + n = len(ids) + if n < 2: + return None + + for _ in range(max_attempts): + receivers = ids[:] + rng.shuffle(receivers) + if any(receivers[i] == ids[i] for i in range(n)): + continue + mapping = dict(zip(ids, receivers)) + if any((giver, receiver) in exclusions for giver, receiver in mapping.items()): + continue + return mapping + return None diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8f71fc6 --- /dev/null +++ b/app/main.py @@ -0,0 +1,496 @@ +"""Wichtel-Werkstatt – FastAPI-Anwendung. + +Routen: + / Raum erstellen oder per Code beitreten + /r/ Teilnehmeransicht (Registrierung / Status / Ergebnis) + /r//admin Admin-Login bzw. Admin-Panel + /r//recover/ Einmal-Link, um ein verlorenes Cookie zu ersetzen +""" + +from __future__ import annotations + +import sqlite3 +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from . import db +from .draw import draw_assignment + +BASE_DIR = Path(__file__).resolve().parent +COOKIE_MAX_AGE = 2 * 365 * 24 * 60 * 60 # 2 Jahre + +MIN_PASSWORD_LENGTH = 4 +MIN_NAME_LENGTH = 1 +MAX_NAME_LENGTH = 50 +MAX_ROOM_NAME_LENGTH = 60 +MIN_PARTICIPANTS_FOR_DRAW = 3 + + +@asynccontextmanager +async def lifespan(_: FastAPI): + db.init_db() + yield + + +app = FastAPI(title="Wichtel-Werkstatt", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") +templates = Jinja2Templates(directory=BASE_DIR / "templates") + + +# ---------- Helpers ---------- + +def conn() -> sqlite3.Connection: + return db.connect() + + +def is_secure(request: Request) -> bool: + """Secure-Cookie nur über HTTPS (direkt oder via X-Forwarded-Proto).""" + forwarded = request.headers.get("x-forwarded-proto", "") + return "https" in forwarded or request.url.scheme == "https" + + +def set_cookie(response, request: Request, name: str, value: str, path: str) -> None: + response.set_cookie( + name, + value, + max_age=COOKIE_MAX_AGE, + httponly=True, + secure=is_secure(request), + samesite="lax", + path=path, + ) + + +def participant_cookie(room_id: str) -> str: + return f"wichtel_p_{room_id}" + + +def admin_cookie(room_id: str) -> str: + return f"wichtel_a_{room_id}" + + +def base_url(request: Request) -> str: + return str(request.base_url).rstrip("/") + + +def share_url(request: Request, code: str) -> str: + return f"{base_url(request)}/r/{code}" + + +def render_message(request: Request, title: str, text: str, status: int = 200) -> HTMLResponse: + return templates.TemplateResponse( + request, "message.html", {"title": title, "text": text}, status_code=status + ) + + +def room_not_found(request: Request) -> HTMLResponse: + return render_message( + request, + "Raum nicht gefunden 🤔", + "Diesen Wichtel-Raum gibt es nicht (mehr). Prüfe den Link oder Code – " + "oder frage den Admin nach dem richtigen Einladungslink.", + status=404, + ) + + +def get_admin_room(request: Request, room) -> bool: + """True, wenn das Admin-Cookie gültig ist.""" + token = request.cookies.get(admin_cookie(room["id"])) + if not token or not room["admin_token_hash"]: + return False + return db.hash_token(token) == room["admin_token_hash"] + + +def admin_panel_response(request: Request, room, recovery_link: str | None = None, + recovery_for: str | None = None, message: str | None = None, + message_kind: str = "ok"): + with conn() as c: + participants = db.list_participants(c, room["id"]) + exclusions = db.list_exclusions(c, room["id"]) + admin_participant = None + ptoken = request.cookies.get(participant_cookie(room["id"])) + if ptoken: + admin_participant = db.get_participant_by_token(c, room["id"], ptoken) + drawn = db.room_is_drawn(room) + can_draw = (not drawn) and len(participants) >= MIN_PARTICIPANTS_FOR_DRAW + return templates.TemplateResponse( + request, + "admin.html", + { + "room": room, + "participants": participants, + "exclusions": exclusions, + "share_link": share_url(request, room["code"]), + "drawn": drawn, + "can_draw": can_draw, + "min_draw": MIN_PARTICIPANTS_FOR_DRAW, + "recovery_link": recovery_link, + "recovery_for": recovery_for, + "admin_participant": admin_participant, + "message": message, + "message_kind": message_kind, + "base": base_url(request), + }, + ) + + +# ---------- Startseite ---------- + +HOME_ERRORS = { + "room_name": "Bitte gib einen Raumnamen an (höchstens 60 Zeichen).", + "password": "Das Admin-Passwort muss mindestens 4 Zeichen lang sein.", + "code": "Zu diesem Code wurde kein Raum gefunden.", +} + + +@app.get("/", response_class=HTMLResponse) +def home(request: Request, error: str | None = None): + return templates.TemplateResponse( + request, "home.html", {"error": HOME_ERRORS.get(error or "")} + ) + + +@app.post("/rooms") +def create_room(request: Request, room_name: str = Form(...), password: str = Form(...)): + room_name = room_name.strip() + if not room_name or len(room_name) > MAX_ROOM_NAME_LENGTH: + return RedirectResponse("/?error=room_name", status_code=303) + if len(password) < MIN_PASSWORD_LENGTH: + return RedirectResponse("/?error=password", status_code=303) + with conn() as c: + room = db.create_room(c, room_name, password) + token = db.new_token() + db.set_admin_token(c, room["id"], token) + response = RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + set_cookie(response, request, admin_cookie(room["id"]), token, f"/r/{room['code']}") + return response + + +@app.post("/join") +def join(request: Request, code: str = Form(...)): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return RedirectResponse("/?error=code", status_code=303) + return RedirectResponse(f"/r/{room['code']}", status_code=303) + + +# ---------- Teilnehmeransicht ---------- + +ROOM_ERRORS = { + "name": "Bitte gib einen Namen an (höchstens 50 Zeichen).", + "duplicate": "Dieser Name ist im Raum schon vergeben – wähle bitte einen anderen " + "(z. B. mit Nachnamen).", +} + + +@app.get("/r/{code}", response_class=HTMLResponse) +def room_page(request: Request, code: str, error: str | None = None): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + + participant = None + token = request.cookies.get(participant_cookie(room["id"])) + if token: + participant = db.get_participant_by_token(c, room["id"], token) + + assignment = None + if participant and db.room_is_drawn(room): + assignment = db.get_assignment_for(c, room["id"], participant["id"]) + + return templates.TemplateResponse( + request, + "room.html", + { + "room": room, + "participant": participant, + "assignment": assignment, + "drawn": db.room_is_drawn(room), + "error": ROOM_ERRORS.get(error or ""), + }, + ) + + +@app.post("/r/{code}/register") +def register(request: Request, code: str, name: str = Form(...)): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if db.room_is_drawn(room): + return render_message( + request, + "Zu spät 🎄", + "In diesem Raum wurde bereits ausgelost – eine Teilnahme ist nicht mehr möglich.", + ) + + name = name.strip() + if len(name) < MIN_NAME_LENGTH or len(name) > MAX_NAME_LENGTH: + return RedirectResponse(f"/r/{room['code']}?error=name", status_code=303) + + token = db.new_token() + try: + participant = db.add_participant(c, room["id"], name, token) + except sqlite3.IntegrityError: + return RedirectResponse(f"/r/{room['code']}?error=duplicate", status_code=303) + + response = RedirectResponse(f"/r/{room['code']}", status_code=303) + set_cookie(response, request, participant_cookie(room["id"]), token, f"/r/{room['code']}") + return response + + +# ---------- Admin ---------- + +@app.get("/r/{code}/admin", response_class=HTMLResponse) +def admin_page(request: Request, code: str): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return templates.TemplateResponse( + request, "admin_login.html", {"room": room, "error": None} + ) + return admin_panel_response(request, room) + + +@app.post("/r/{code}/admin/login") +def admin_login(request: Request, code: str, password: str = Form(...)): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not db.verify_password(password, room["password_hash"]): + return templates.TemplateResponse( + request, "admin_login.html", {"room": room, "error": True} + ) + token = db.new_token() + db.set_admin_token(c, room["id"], token) + response = RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + set_cookie(response, request, admin_cookie(room["id"]), token, f"/r/{room['code']}") + return response + + +@app.post("/r/{code}/admin/logout") +def admin_logout(request: Request, code: str): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + db.clear_admin_token(c, room["id"]) + response = RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + response.delete_cookie(admin_cookie(room["id"]), path=f"/r/{room['code']}") + return response + + +@app.post("/r/{code}/admin/self-register") +def admin_self_register(request: Request, code: str, name: str = Form(...)): + """Der Admin nimmt selbst am Wichteln teil: legt ihn als Teilnehmer an + und setzt zusätzlich den Teilnehmer-Cookie in seinem Browser.""" + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + if db.room_is_drawn(room): + return admin_panel_response( + request, room, + message="Nach der Auslosung ist keine Teilnahme mehr möglich.", + message_kind="error", + ) + name = name.strip() + if len(name) < MIN_NAME_LENGTH or len(name) > MAX_NAME_LENGTH: + return admin_panel_response( + request, room, + message="Bitte gib einen gültigen Namen an (höchstens 50 Zeichen).", + message_kind="error", + ) + token = db.new_token() + try: + db.add_participant(c, room["id"], name, token) + except sqlite3.IntegrityError: + return admin_panel_response( + request, room, message="Dieser Name ist bereits vergeben.", + message_kind="error", + ) + response = admin_panel_response( + request, room, + message=f"Schön, dass du dabei bist: Du wichtelst als »{name}« mit! 🎅 " + "Deine Teilnehmeransicht (und später dein Ergebnis) erreichst du " + "über den Einladungslink.", + message_kind="ok", + ) + set_cookie(response, request, participant_cookie(room["id"]), token, f"/r/{room['code']}") + return response + + +@app.post("/r/{code}/participants/{pid}/delete") +def delete_participant(request: Request, code: str, pid: str): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + if db.room_is_drawn(room): + return admin_panel_response( + request, room, message="Nach der Auslosung können keine Teilnehmer mehr gelöscht werden.", + message_kind="error", + ) + db.delete_participant(c, room["id"], pid) + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + + +@app.post("/r/{code}/exclusions/add") +def add_exclusion(request: Request, code: str, from_id: str = Form(...), to_id: str = Form(...)): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + if db.room_is_drawn(room): + return admin_panel_response( + request, room, message="Nach der Auslosung können keine Ausschlüsse mehr geändert werden.", + message_kind="error", + ) + if from_id == to_id: + return admin_panel_response( + request, room, message="Ein Teilnehmer kann sich nicht selbst ausgeschlossen werden.", + message_kind="error", + ) + db.add_exclusion(c, room["id"], from_id, to_id) + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + + +@app.post("/r/{code}/exclusions/{from_id}/{to_id}/delete") +def delete_exclusion(request: Request, code: str, from_id: str, to_id: str): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + if db.room_is_drawn(room): + return admin_panel_response( + request, room, message="Nach der Auslosung können keine Ausschlüsse mehr geändert werden.", + message_kind="error", + ) + db.remove_exclusion(c, room["id"], from_id, to_id) + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + + +@app.post("/r/{code}/draw") +def draw(request: Request, code: str): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + if db.room_is_drawn(room): + return admin_panel_response( + request, room, message="Es wurde bereits ausgelost.", message_kind="error" + ) + participants = db.list_participants(c, room["id"]) + if len(participants) < MIN_PARTICIPANTS_FOR_DRAW: + return admin_panel_response( + request, room, + message=f"Mindestens {MIN_PARTICIPANTS_FOR_DRAW} Teilnehmer nötig – aktuell sind es {len(participants)}.", + message_kind="error", + ) + ids = [p["id"] for p in participants] + mapping = draw_assignment(ids, db.exclusion_pairs(c, room["id"])) + if mapping is None: + return admin_panel_response( + request, room, + message="Mit diesen Ausschlüssen ist keine gültige Auslosung möglich. " + "Bitte entferne oder lockere Ausschlüsse.", + message_kind="error", + ) + db.save_assignments(c, room["id"], mapping) + room = db.get_room_by_id(c, room["id"]) + return admin_panel_response( + request, room, + message="🎉 Die Auslosung ist abgeschlossen! Die Teilnehmer sehen ihr Ergebnis, " + "wenn sie ihren Einladungslink öffnen.", + message_kind="ok", + ) + + +# ---------- Recovery-Links (Cookie verloren) ---------- + +@app.post("/r/{code}/recovery/{pid}") +def create_recovery_link(request: Request, code: str, pid: str): + """Erzeugt einen Einmal-Link, mit dem der Teilnehmer sein Cookie zurückbekommt. + + Bewusst auch nach der Auslosung erlaubt: Der Admin sieht dabei weiterhin + nicht, wer wen gezogen hat – der Link stellt nur die Sicht des Teilnehmers + selbst wieder her. + """ + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + if not get_admin_room(request, room): + return RedirectResponse(f"/r/{room['code']}/admin", status_code=303) + participant = db.get_participant(c, pid) + if participant is None or participant["room_id"] != room["id"]: + return admin_panel_response( + request, room, message="Teilnehmer nicht gefunden.", message_kind="error" + ) + token = db.new_token() + db.create_recovery_link(c, room["id"], pid, token) + link = f"{share_url(request, room['code'])}/recover/{token}" + return admin_panel_response( + request, room, recovery_link=link, recovery_for=participant["name"], + message=None, + ) + + +@app.get("/r/{code}/recover/{token}", response_class=HTMLResponse) +def redeem_recovery_link(request: Request, code: str, token: str): + with conn() as c: + room = db.get_room_by_code(c, code) + if room is None: + return room_not_found(request) + link = db.get_recovery_link(c, token) + if link is None or link["room_id"] != room["id"]: + return render_message( + request, + "Link ungültig 🔒", + "Dieser Wiederherstellungslink ist ungültig. Bitte den Admin um einen neuen Link.", + status=404, + ) + if link["used_at"] is not None: + return render_message( + request, + "Link bereits verwendet 🔒", + "Dieser Wiederherstellungslink wurde schon benutzt. " + "Falls du erneut Zugriff brauchst, bitte den Admin um einen neuen Link.", + ) + new_token = db.new_token() + db.set_participant_token(c, link["participant_id"], new_token) + db.mark_recovery_link_used(c, link["token_hash"]) + + response = RedirectResponse(f"/r/{room['code']}", status_code=303) + set_cookie(response, request, participant_cookie(room["id"]), new_token, f"/r/{room['code']}") + return response + + +@app.exception_handler(sqlite3.Error) +def sqlite_error(request: Request, exc: sqlite3.Error): + return render_message( + request, + "Ups ❄", + "Es ist ein Fehler beim Speichern aufgetreten. Bitte versuche es noch einmal.", + status=500, + ) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..ce62ca1 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,37 @@ +// Wichtel-Werkstatt: Copy-Buttons und Bestätigungsdialoge. + +document.addEventListener("click", (event) => { + const button = event.target.closest("[data-copy]"); + if (!button) return; + const source = document.querySelector(button.dataset.copy); + if (!source) return; + const text = source.value || source.textContent; + const done = () => { + const original = button.textContent; + button.textContent = "Kopiert ✓"; + setTimeout(() => { button.textContent = original; }, 1600); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(source, done)); + } else { + fallbackCopy(source, done); + } +}); + +function fallbackCopy(source, done) { + source.focus(); + source.select(); + try { + document.execCommand("copy"); + done(); + } catch { + // Kopieren nicht möglich – Text bleibt im Feld zum manuellen Markieren. + } +} + +document.addEventListener("submit", (event) => { + const form = event.target.closest("form[data-confirm]"); + if (form && !window.confirm(form.dataset.confirm)) { + event.preventDefault(); + } +}); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..543810c --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,281 @@ +/* Wichtel-Werkstatt – mobil-zuerst, weihnachtliche Farbpalette */ + +:root { + --green: #1d5c3f; + --green-dark: #123f2b; + --red: #a4243b; + --red-dark: #831b2e; + --gold: #c9a227; + --gold-soft: #f3e6c0; + --cream: #faf4e8; + --card: #ffffff; + --ink: #2f2a26; + --muted: #6f675e; + --line: #e6dccb; + --radius: 14px; +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 1rem; + line-height: 1.55; + color: var(--ink); + background-color: var(--cream); + /* dezente Schneeflocken-Tupfer */ + background-image: + radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.9) 0 3px, transparent 4px), + radial-gradient(circle at 78% 8%, rgba(255, 255, 255, 0.8) 0 2px, transparent 3px), + radial-gradient(circle at 55% 42%, rgba(255, 255, 255, 0.7) 0 2px, transparent 3px), + radial-gradient(circle at 28% 72%, rgba(255, 255, 255, 0.8) 0 3px, transparent 4px), + radial-gradient(circle at 90% 60%, rgba(255, 255, 255, 0.7) 0 2px, transparent 3px), + linear-gradient(180deg, #f4e9d5 0%, var(--cream) 240px); + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +/* ---------- Kopf & Fuß ---------- */ + +.site-header { + padding: 0.9rem 1rem 0.4rem; + text-align: center; +} + +.brand { + font-size: 1.35rem; + font-weight: 800; + color: var(--red); + text-decoration: none; + letter-spacing: 0.02em; +} + +.site-footer { + margin-top: auto; + padding: 1.4rem 1rem 1.8rem; + text-align: center; + color: var(--muted); + font-size: 0.85rem; +} + +main { + width: 100%; + max-width: 30rem; + margin: 0 auto; + padding: 0.6rem 0.9rem 1.5rem; + flex: 1; +} + +/* ---------- Typo & Bausteine ---------- */ + +h1 { font-size: 1.5rem; line-height: 1.25; margin: 0.8rem 0 0.6rem; color: var(--green-dark); } +h2 { font-size: 1.12rem; margin: 0 0 0.6rem; color: var(--green-dark); } + +.hero { text-align: center; margin: 0.4rem 0 1rem; } +.hero h1 { font-size: 1.9rem; color: var(--red); } +.hero p { color: var(--muted); margin: 0 auto; max-width: 26rem; } + +.room-title { text-align: center; } + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 6px 18px rgba(90, 60, 20, 0.08); + padding: 1.1rem 1rem; + margin: 0.9rem 0; +} + +.card-highlight { + border-color: var(--gold); + background: linear-gradient(180deg, #fffdf4, var(--card)); +} + +.card-result { text-align: center; } + +.divider { + display: flex; + align-items: center; + gap: 0.6rem; + color: var(--muted); + font-size: 0.85rem; + margin: 1.1rem 0.4rem; +} +.divider::before, +.divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +.hint { color: var(--muted); font-size: 0.85rem; } +.hello { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.3rem; } +.waiting { margin-top: 0.4rem; } + +.result-label { margin: 0.8rem 0 0.2rem; color: var(--muted); } +.result-name { + font-size: 1.7rem; + font-weight: 800; + color: var(--red); + background: var(--gold-soft); + border: 2px dashed var(--gold); + border-radius: var(--radius); + padding: 0.8rem 0.6rem; + margin: 0.4rem 0 0.9rem; + overflow-wrap: anywhere; +} + +/* ---------- Formulare ---------- */ + +.stack { display: flex; flex-direction: column; gap: 0.8rem; } + +label { display: flex; flex-direction: column; gap: 0.25rem; font-weight: 600; font-size: 0.95rem; } +label small { font-weight: 400; color: var(--muted); } + +input[type="text"], +input[type="password"], +select { + width: 100%; + min-height: 48px; + padding: 0.55rem 0.8rem; + font-size: 1rem; + font-family: inherit; + color: var(--ink); + background: #fff; + border: 1.5px solid var(--line); + border-radius: 10px; +} +input:focus, select:focus { + outline: 2px solid var(--green); + outline-offset: 1px; + border-color: var(--green); +} +input[readonly] { background: var(--cream); color: var(--green-dark); font-size: 0.9rem; } + +/* ---------- Buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 48px; + padding: 0.6rem 1.2rem; + font-size: 1rem; + font-weight: 700; + font-family: inherit; + border: none; + border-radius: 12px; + cursor: pointer; + text-decoration: none; + transition: transform 0.05s ease, background-color 0.15s ease; +} +.btn:active { transform: scale(0.98); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-primary { background: var(--green); color: #fff; } +.btn-primary:hover:not(:disabled) { background: var(--green-dark); } + +.btn-secondary { background: var(--red); color: #fff; } +.btn-secondary:hover:not(:disabled) { background: var(--red-dark); } + +.btn-danger { background: var(--red); color: #fff; } +.btn-danger:hover:not(:disabled) { background: var(--red-dark); } + +.btn-ghost { + background: transparent; + color: var(--muted); + border: 1.5px solid var(--line); +} + +.btn-big { width: 100%; min-height: 56px; font-size: 1.1rem; } + +.btn-small { min-height: 38px; padding: 0.3rem 0.75rem; font-size: 0.9rem; } + +.logout-row { display: flex; justify-content: center; margin: 1.2rem 0 0.4rem; } + +/* ---------- Listen & Admin ---------- */ + +.admin-head { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.tag { + display: inline-block; + padding: 0.15rem 0.65rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + background: var(--green); + color: #fff; +} +.tag-gold { background: var(--gold); color: #3d2f05; } + +.list { list-style: none; margin: 0.5rem 0 0; padding: 0; } +.list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + padding: 0.55rem 0.2rem; + border-bottom: 1px solid var(--line); +} +.list-item:last-child { border-bottom: none; } +.list-item form { margin: 0; } +.list-name { overflow-wrap: anywhere; } + +.copy-row { display: flex; gap: 0.5rem; margin: 0.5rem 0; } +.copy-row input { flex: 1; min-width: 0; } + +.code-line { margin: 0.6rem 0 0; color: var(--muted); } +.code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.18em; + color: var(--green-dark); + background: var(--gold-soft); + border-radius: 8px; + padding: 0.15rem 0.55rem; +} + +.exclusion-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin: 0.7rem 0 0.4rem; +} +.exclusion-form .btn { grid-column: 1 / -1; } + +/* ---------- Meldungen ---------- */ + +.alert { + border-radius: var(--radius); + padding: 0.75rem 0.9rem; + margin: 0.8rem 0; + font-size: 0.95rem; +} +.alert-ok { background: #e7f2ea; border: 1px solid var(--green); color: var(--green-dark); } +.alert-error { background: #fae8ec; border: 1px solid var(--red); color: var(--red-dark); } + +.card-message { text-align: center; } +.card-message .btn { margin-top: 0.8rem; } + +/* ---------- Desktop ---------- */ + +@media (min-width: 640px) { + main { padding-top: 1.2rem; } + .card { padding: 1.4rem 1.4rem; } + .hero h1 { font-size: 2.3rem; } + .exclusion-form { grid-template-columns: 1fr 1fr auto; } + .exclusion-form .btn { grid-column: auto; } +} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..27cf5ec --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,167 @@ +{% extends "base.html" %} +{% block title %}Admin – {{ room.name }}{% endblock %} +{% block content %} +
+

{{ room.name }}

+ {% if drawn %} + ✨ Ausgelost + {% else %} + Wartet auf Auslosung + {% endif %} +
+ +{% if message %} + +{% endif %} + +{% if recovery_link %} +
+

🔑 Wiederherstellungslink für {{ recovery_for }}

+

Schicke diesen Link direkt an {{ recovery_for }}. + Er funktioniert ein einziges Mal und stellt dort das + Ergebnis wieder her:

+
+ + +
+

Ein eventuell älterer, unbenutzter Link für {{ recovery_for }} + wurde dadurch ersetzt.

+
+{% endif %} + +
+

Einladung teilen

+

Mit diesem Link melden sich die Teilnehmer an:

+
+ + +
+

Raumcode: {{ room.code }}

+
+ +{% if not drawn %} +
+

Selbst mitwichteln 🎅

+ {% if admin_participant %} +

Du bist als {{ admin_participant.name }} mit dabei. ✅ + Deine Teilnehmeransicht – und später dein Ergebnis – erreichst du über + den Einladungslink.

+ {% else %} +

Auch der Admin braucht ein Los: Melde dich selbst als Teilnehmer an.

+
+ + +
+ {% endif %} +
+{% endif %} + +
+

Teilnehmer ({{ participants|length }})

+ {% if participants %} +
    + {% for p in participants %} +
  • + {{ p.name }} + {% if not drawn %} +
    + +
    + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

Noch niemand dabei – teile den Einladungslink!

+ {% endif %} + {% if drawn %}

Nach der Auslosung ist die Teilnehmerliste eingefroren.

{% endif %} +
+ +
+

Ausschlüsse

+

Wer darf wen nicht ziehen? (z. B. Ehepartner gegenseitig + ausschließen – das sind zwei Einträge)

+ {% if not drawn %} +
+ + + +
+ {% endif %} + {% if exclusions %} +
    + {% for e in exclusions %} +
  • + {{ e.from_name }} ➜ darf nicht ➜ {{ e.to_name }} + {% if not drawn %} +
    + +
    + {% endif %} +
  • + {% endfor %} +
+ {% elif drawn %} +

Keine Ausschlüsse festgelegt.

+ {% endif %} + {% if drawn %}

Nach der Auslosung sind die Ausschlüsse eingefroren.

{% endif %} +
+ +
+

Auslosung

+ {% if drawn %} +
🎉 Es wurde ausgelost! Wer wen beschenkt, sehen nur die + Teilnehmer selbst auf ihrer Seite – hier im Admin-Bereich bleibt das geheim.
+ {% else %} +

Jede Person zieht genau eine andere Person – niemand zieht sich selbst, + Ausschlüsse werden respektiert.

+
+ +
+ {% if not can_draw %} +

Mindestens {{ min_draw }} Teilnehmer nötig + (aktuell {{ participants|length }}).

+ {% endif %} + {% endif %} +
+ +
+

Zugriff wiederherstellen 🔑

+

Hat jemand das Cookie gelöscht oder ein neues Gerät? + Erstelle hier einen Einmal-Link, mit dem die Person ihr Ergebnis wiedersehen kann – + auch nach der Auslosung. Du erfährst dabei weiterhin nicht, wer wen gezogen hat.

+
    + {% for p in participants %} +
  • + {{ p.name }} +
    + +
    +
  • + {% endfor %} +
+ {% if not participants %}

Noch keine Teilnehmer im Raum.

{% endif %} +
+ +
+ +
+{% endblock %} diff --git a/app/templates/admin_login.html b/app/templates/admin_login.html new file mode 100644 index 0000000..a35389b --- /dev/null +++ b/app/templates/admin_login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Admin-Login – {{ room.name }}{% endblock %} +{% block content %} +

{{ room.name }}

+
+

Admin-Bereich 🔑

+ {% if error %}{% endif %} +
+ + +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..416553d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,20 @@ + + + + + + {% block title %}Wichtel-Werkstatt{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+
🎄 Fröhliches Wichteln! 🎄
+ + + diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..f166a3a --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Wichtel-Werkstatt – Raum erstellen oder beitreten{% endblock %} +{% block content %} +
+

Wichtel-Werkstatt

+

Wichteln ohne Zettel-Chaos: Raum erstellen, Mitmenschen einladen, + Ausschlüsse festlegen und fair auslosen. 🎁

+
+ +{% if error %}{% endif %} + +
+

Raum erstellen

+
+ + + +
+
+ + + +
+

Mit Code beitreten

+
+ + +
+
+{% endblock %} diff --git a/app/templates/message.html b/app/templates/message.html new file mode 100644 index 0000000..608d9ec --- /dev/null +++ b/app/templates/message.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}{{ title }} – Wichtel-Werkstatt{% endblock %} +{% block content %} +
+

{{ title }}

+

{{ text }}

+ Zur Startseite +
+{% endblock %} diff --git a/app/templates/room.html b/app/templates/room.html new file mode 100644 index 0000000..cd16b06 --- /dev/null +++ b/app/templates/room.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}{{ room.name }} – Wichtel-Werkstatt{% endblock %} +{% block content %} +

{{ room.name }}

+ +{% if error %}{% endif %} + +{% if participant %} + {% if drawn %} +
+

Ho ho ho, {{ participant.name }}! 🎅

+

Du beschenkst in diesem Jahr:

+

🎁 {{ assignment.receiver_name if assignment else "…" }}

+

Verrate es niemandem – die Überraschung soll echt sein! 🤫

+

Diese Seite merkt sich dein Ergebnis über deinen Browser. + Bleib also auf diesem Gerät oder frage den Admin nach einem neuen Link, + falls du den Zugriff verlierst.

+
+ {% else %} +
+

Hi {{ participant.name }}! 👋

+

Du bist als Wichtel angemeldet. ✅

+

⏳ Jetzt heißt es warten, bis die Auslosung startet. + Schau einfach später nochmal hier vorbei – dein Ergebnis erscheint + auf genau dieser Seite.

+

Wichtig: Diese Seite merkt sich dich über deinen Browser. + Bitte lösche das Cookie nicht und bleib auf diesem Gerät.

+
+ {% endif %} +{% else %} + {% if drawn %} +
+

Hier wurde bereits ausgelost – eine Teilnahme ist leider nicht mehr möglich. 🎄

+
+ {% else %} +
+

Mitmachen

+

Gib deinen Namen ein, um bei {{ room.name }} mitzuwichteln.

+
+ + +
+

Nach der Anmeldung merkt sich diese Seite dich über deinen + Browser – so siehst du später hier dein Ergebnis.

+
+ {% endif %} +{% endif %} +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..522598c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + wichteln: + build: . + container_name: wichteln + restart: unless-stopped + ports: + # Nur lokal binden – öffentlich macht der nginx-Reverse-Proxy. + - "127.0.0.1:8000:8000" + volumes: + # SQLite-Datei liegt hier – dieses Verzeichnis sichern = komplettes Backup. + # :z setzt das SELinux-Label für Container-Zugriff (auf Systemen ohne + # SELinux ein wirkungsloser No-op). + - ./data:/data:z diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..809af97 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +if [ "$(id -u)" = "0" ]; then + # Bind-Mounts (z. B. docker compose mit ./data) kommen als root an: + # Besitz anpassen und dann Rechte an den unprivilegierten Nutzer abgeben. + if chown wichtel:wichtel /data 2>/dev/null; then + exec setpriv --reuid=wichtel --regid=wichtel --clear-groups \ + uvicorn app.main:app --host 0.0.0.0 --port 8000 + fi + # chown nicht möglich (rootless podman): "root" im Container ist hier + # nur der normale Host-Nutzer – direkt weiterlaufen. +fi + +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/nginx.example.conf b/nginx.example.conf new file mode 100644 index 0000000..09241ac --- /dev/null +++ b/nginx.example.conf @@ -0,0 +1,28 @@ +# Beispiel: Wichtel-Werkstatt hinter nginx als Reverse Proxy. +# Der Container lauscht auf 127.0.0.1:8000 (siehe docker-compose.yml). +# +# HTTPS wird dringend empfohlen – ohne HTTPS werden die Cookies (und damit +# die Ergebnisse) unverschlüsselt übertragen. Zertifikate z. B. via certbot: +# certbot --nginx -d wichteln.example.org + +server { + listen 80; + listen [::]:80; + server_name wichteln.example.org; + + # Optional: HTTP komplett auf HTTPS umleiten + # return 301 https://$host$request_uri; + + location / { + proxy_pass http://127.0.0.1:8000; + + # Wichtig: Host und Protokoll weiterreichen, damit die App + # korrekte Links erzeugt und Cookies das Secure-Flag bekommen. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + proxy_http_version 1.1; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..61dc2f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +jinja2>=3.1 +python-multipart>=0.0.9 diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100755 index 0000000..4bece9e --- /dev/null +++ b/tests/e2e.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# End-to-End-Test gegen eine laufende Instanz. +# Aufruf: BASE=http://localhost:8080 bash tests/e2e.sh +set -euo pipefail + +BASE="${BASE:-http://localhost:8080}" +JAR_DIR="$(mktemp -d)" +trap 'rm -rf "$JAR_DIR"' EXIT + +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +fail() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +check() { # check + if [ "${2:-0}" = "1" ]; then ok "$1"; else fail "$1"; fi +} + +redirect_of() { # POST mit Formulardaten, liefert Location-Header + curl -s -o /dev/null -w '%{redirect_url}' "${@}" +} + +echo "== Raum erstellen ==" +LOC=$(curl -s -c "$JAR_DIR/admin" -o /dev/null -w '%{redirect_url}' \ + --data-urlencode "room_name=E2E Raum" \ + --data-urlencode "password=strenggeheim" \ + "$BASE/rooms") +CODE=$(printf '%s' "$LOC" | sed -E 's|.*/r/([A-Z2-9]{6})/admin|\1|') +check "Raum angelegt, Code=$CODE" "$([ ${#CODE} -eq 6 ] && echo 1 || echo 0)" + +PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin") +echo "$PANEL" | grep -q "Einladung teilen" && ok "Admin-Panel erreichbar" || fail "Admin-Panel erreichbar" + +echo "== Falsches Admin-Passwort ==" +curl -s -c "$JAR_DIR/fake" -o /dev/null --data-urlencode "password=falsch" "$BASE/r/$CODE/admin/login" +FAKE=$(curl -s -b "$JAR_DIR/fake" "$BASE/r/$CODE/admin") +echo "$FAKE" | grep -q "Admin-Bereich" && ok "Falsches Passwort bleibt im Login" || fail "Falsches Passwort bleibt im Login" + +echo "== Teilnehmer registrieren ==" +NAMES=(Anna Ben Clara David) +for n in "${NAMES[@]}"; do + LOC=$(curl -s -c "$JAR_DIR/$n" -o /dev/null -w '%{redirect_url}' \ + --data-urlencode "name=$n" "$BASE/r/$CODE/register") + case "$LOC" in + */r/"$CODE") ok "Registrierung $n" ;; + *) fail "Registrierung $n (Redirect: $LOC)" ;; + esac +done + +LOC=$(curl -s -c "$JAR_DIR/dup" -o /dev/null -w '%{redirect_url}' \ + --data-urlencode "name=anna" "$BASE/r/$CODE/register") +echo "$LOC" | grep -q "error=duplicate" && ok "Doppelter Name wird abgelehnt" || fail "Doppelter Name wird abgelehnt" + +echo "== Admin nimmt selbst teil ==" +SELF=$(curl -s -b "$JAR_DIR/admin" -c "$JAR_DIR/admin" --data-urlencode "name=Nico" "$BASE/r/$CODE/admin/self-register") +echo "$SELF" | grep -q "wichtelst als »Nico«" && ok "Admin als Teilnehmer angemeldet" || fail "Admin als Teilnehmer angemeldet" +WAIT=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE") +echo "$WAIT" | grep -q "Hi Nico" && ok "Admin sieht eigene Teilnehmeransicht" || fail "Admin sieht eigene Teilnehmeransicht" +SELF2=$(curl -s -b "$JAR_DIR/admin" --data-urlencode "name=Nico" "$BASE/r/$CODE/admin/self-register") +echo "$SELF2" | grep -q "bereits vergeben" && ok "Admin-Doppelname wird abgelehnt" || fail "Admin-Doppelname wird abgelehnt" +NAMES=(Anna Ben Clara David Nico) +# Nicos Teilnehmer-Cookie lebt im Admin-Jar – für die Ergebnis-Prüfung kopieren +cp "$JAR_DIR/admin" "$JAR_DIR/Nico" + +echo "== Ausschlüsse (Anna darf Ben nicht ziehen, Ben darf Anna nicht ziehen) ==" +PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin") +id_of() { printf '%s' "$PANEL" | grep -oE "value=\"[^\"]+\">$1<" | head -1 | sed -E 's/value="([^"]+)".*/\1/'; } +ANNA_ID=$(id_of Anna); BEN_ID=$(id_of Ben) +check "Teilnehmer-IDs gefunden" "$([ -n "$ANNA_ID" ] && [ -n "$BEN_ID" ] && echo 1 || echo 0)" + +curl -s -b "$JAR_DIR/admin" -o /dev/null \ + --data-urlencode "from_id=$ANNA_ID" --data-urlencode "to_id=$BEN_ID" "$BASE/r/$CODE/exclusions/add" +curl -s -b "$JAR_DIR/admin" -o /dev/null \ + --data-urlencode "from_id=$BEN_ID" --data-urlencode "to_id=$ANNA_ID" "$BASE/r/$CODE/exclusions/add" +PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin") +echo "$PANEL" | grep -q "Anna ➜ darf nicht ➜ Ben" && ok "Ausschluss sichtbar" || fail "Ausschluss sichtbar" + +echo "== Auslosen ==" +DRAW=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/draw") +echo "$DRAW" | grep -q "Auslosung ist abgeschlossen" && ok "Auslosung erfolgreich" || fail "Auslosung erfolgreich" + +echo "== Ergebnisse der Teilnehmer ==" +declare -A RECEIVER +for n in "${NAMES[@]}"; do + PAGE=$(curl -s -b "$JAR_DIR/$n" "$BASE/r/$CODE") + R=$(printf '%s' "$PAGE" | grep -oE 'result-name">🎁 [^<]+' | sed 's/result-name">🎁 //') + RECEIVER[$n]="$R" + if [ -n "$R" ] && [ "$R" != "$n" ]; then + ok "$n beschenkt $R" + else + fail "Ergebnis für $n (Wert: '$R')" + fi +done + +SORTED=$(printf '%s\n' "${RECEIVER[@]}" | sort) +UNIQUE=$(printf '%s\n' "${RECEIVER[@]}" | sort -u) +check "Jede Person wird genau einmal beschenkt" "$([ "$SORTED" = "$UNIQUE" ] && echo 1 || echo 0)" + +check "Ausschluss eingehalten: Anna ≠> Ben" "$([ "${RECEIVER[Anna]}" != "Ben" ] && echo 1 || echo 0)" +check "Ausschluss eingehalten: Ben ≠> Anna" "$([ "${RECEIVER[Ben]}" != "Anna" ] && echo 1 || echo 0)" + +echo "== Admin sieht keine Zuweisungen ==" +PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin") +echo "$PANEL" | grep -q "Ausgelost" && ok "Admin sieht Auslosungs-Status" || fail "Admin sieht Auslosungs-Status" +LEAK=1 +echo "$PANEL" | grep -q "Du beschenkst" && LEAK=0 +for n in "${NAMES[@]}"; do + echo "$PANEL" | grep -qE "$n (beschenkt|zieht)" && LEAK=0 +done +check "Keine Ergebnis-Details im Admin-Panel" "$LEAK" + +echo "== Raum ist eingefroren ==" +DEL=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/participants/$ANNA_ID/delete") +echo "$DEL" | grep -q "eingefroren\|können keine" && ok "Teilnehmer löschen nach Auslosung blockiert" || fail "Teilnehmer löschen nach Auslosung blockiert" +DRAW2=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/draw") +echo "$DRAW2" | grep -q "bereits ausgelost" && ok "Zweite Auslosung blockiert" || fail "Zweite Auslosung blockiert" +LATE=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$JAR_DIR/late" --data-urlencode "name=Zuspät" "$BASE/r/$CODE/register") +PAGE=$(curl -s -b "$JAR_DIR/late" "$BASE/r/$CODE") +echo "$PAGE" | grep -q "nicht mehr möglich" && ok "Keine Registrierung nach Auslosung" || fail "Keine Registrierung nach Auslosung" + +echo "== Recovery-Link (Cookie verloren) ==" +RECOV=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/recovery/$ANNA_ID") +LINK=$(printf '%s' "$RECOV" | grep -oE 'value="[^"]+/recover/[^"]+"' | sed -E 's/^value="//; s/"$//') +check "Recovery-Link erzeugt" "$([ -n "$LINK" ] && echo 1 || echo 0)" + +LOC=$(curl -s -c "$JAR_DIR/anna_neu" -o /dev/null -w '%{redirect_url}' "$LINK") +case "$LOC" in + http://*|https://*) TARGET="$LOC" ;; + *) TARGET="$BASE$LOC" ;; +esac +PAGE=$(curl -s -b "$JAR_DIR/anna_neu" "$TARGET") +if echo "$PAGE" | grep -q "Ho ho ho, Anna" && echo "$PAGE" | grep -q "🎁 ${RECEIVER[Anna]}"; then + ok "Anna sieht mit neuem Cookie wieder ihr Ergebnis (${RECEIVER[Anna]})" +else + fail "Anna sieht mit neuem Cookie wieder ihr Ergebnis" +fi + +PAGE=$(curl -s -L "$LINK") +echo "$PAGE" | grep -q "bereits verwendet" && ok "Recovery-Link ist Einmal-Link" || fail "Recovery-Link ist Einmal-Link" + +echo +echo "ERGEBNIS: $PASS ok, $FAIL fehlgeschlagen" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_draw.py b/tests/test_draw.py new file mode 100644 index 0000000..f325002 --- /dev/null +++ b/tests/test_draw.py @@ -0,0 +1,85 @@ +"""Tests für die Auslosungslogik. + +Laufen ohne Abhängigkeiten: python tests/test_draw.py +(sind aber auch pytest-kompatibel) +""" + +from __future__ import annotations + +import random +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.draw import draw_assignment + + +def check_mapping(mapping, ids, exclusions): + assert sorted(mapping.keys()) == sorted(ids), "jeder muss genau einmal geben" + assert sorted(mapping.values()) == sorted(ids), "jeder muss genau einmal gezogen werden" + for giver, receiver in mapping.items(): + assert giver != receiver, "niemand darf sich selbst ziehen" + assert (giver, receiver) not in exclusions, "Ausschlüsse müssen gelten" + + +def test_basically(): + ids = ["a", "b", "c", "d"] + mapping = draw_assignment(ids, set()) + assert mapping is not None + check_mapping(mapping, ids, set()) + + +def test_mindestgroesse(): + assert draw_assignment([], set()) is None + assert draw_assignment(["a"], set()) is None + + +def test_ausschluesse_werden_respektiert(): + ids = ["anna", "ben", "clara", "david"] + # Ehepaar: Anna und Ben dürfen sich nicht gegenseitig ziehen. + exclusions = {("anna", "ben"), ("ben", "anna")} + rng = random.Random(42) + for _ in range(300): + mapping = draw_assignment(ids, exclusions, rng=rng) + assert mapping is not None + check_mapping(mapping, ids, exclusions) + + +def test_unloesbar_zwei_personen_mit_ausschluss(): + ids = ["a", "b"] + assert draw_assignment(ids, {("a", "b")}) is None + + +def test_unloesbar_kein_moeglicher_empfaenger(): + # a darf weder b noch c ziehen → keine gültige Auslosung möglich. + ids = ["a", "b", "c"] + exclusions = {("a", "b"), ("a", "c")} + assert draw_assignment(ids, exclusions) is None + + +def test_zufall_variiert(): + ids = [f"p{i}" for i in range(8)] + exclusions = {("p0", "p1"), ("p2", "p3")} + rng = random.Random(7) + results = set() + for _ in range(100): + mapping = draw_assignment(ids, exclusions, rng=rng) + assert mapping is not None + check_mapping(mapping, ids, exclusions) + results.add(tuple(sorted(mapping.items()))) + assert len(results) > 1, "Auslosung soll zufällig variieren" + + +if __name__ == "__main__": + failures = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f" ✓ {name}") + except AssertionError as e: + failures += 1 + print(f" ✗ {name}: {e}") + print("ALLE TESTS OK" if failures == 0 else f"{failures} TEST(S) FEHLGESCHLAGEN") + sys.exit(1 if failures else 0)