Files
wichteln/app/db.py
T
nico d56be7b2c0 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)
2026-08-05 16:07:21 +02:00

286 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
)