- 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)
286 lines
9.1 KiB
Python
286 lines
9.1 KiB
Python
"""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),
|
||
)
|