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:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user