"""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), )