Wichtel-Werkstatt: Secret-Santa-App mit Räumen, Ausschlüssen und Recovery-Links

- 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)
This commit is contained in:
2026-08-05 16:07:21 +02:00
commit d56be7b2c0
21 changed files with 1839 additions and 0 deletions
+285
View File
@@ -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),
)