"""Konfiguration der Anwendung. Alle Werte kommen aus Umgebungsvariablen, damit dieselbe Codebasis lokal (``.env``) und auf cPanel (Umgebungsvariablen in „Setup Python App“) unverändert läuft. """ from __future__ import annotations import logging import os import secrets import time from collections.abc import Mapping, MutableMapping from pathlib import Path from typing import Any #: Projektordner (enthält ``passenger_wsgi.py``). Alle relativen Pfade werden hieran #: verankert, weil Passenger die App nicht zwingend aus diesem Ordner heraus startet. PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent INSTANCE_DIR: Path = PROJECT_ROOT / "instance" DEFAULT_DB_PATH: Path = INSTANCE_DIR / "glory_defense.sqlite3" SECRET_KEY_FILE: Path = INSTANCE_DIR / "secret_key" #: Optionale Sperrliste für Spielernamen (ein Begriff pro Zeile). Fehlt sie, ist nichts gesperrt. DEFAULT_BLOCKLIST_PATH: Path = INSTANCE_DIR / "name_blocklist.txt" #: Maximale Größe eines Requests. Highscore-Einträge sind winzig; alles darüber ist Missbrauch. MAX_CONTENT_LENGTH_BYTES = 16 * 1024 #: Grenzen für die Plausibilitätsprüfung eingereichter Punktestände. #: Absichtlich großzügig: Die Spiellogik läuft im Browser, der Server kann deshalb nur #: offensichtlich unmögliche Werte aussortieren, echtes Cheaten aber nicht verhindern. #: Die Werte sind auf die Balancing-Werte in ``app/static/js/config.js`` abgestimmt #: (Herleitung in ``docs/GAME_DESIGN.md``). GAME_RULE_DEFAULTS: dict[str, int] = { "HIGHSCORE_TOP_N": 10, "SCORE_MAX": 50_000_000, # Sockel, damit sehr kurze Runden mit einem Glückstreffer nicht abgelehnt werden. "SCORE_FLAT_ALLOWANCE": 2_000, # Obergrenze für den Punkte-Durchschnitt über die gesamte Runde (realistisch 15–120/s). "SCORE_MAX_POINTS_PER_SECOND": 200, "WAVE_MAX": 999, # Jede abgeschlossene Welle kostet mindestens diese Zeit (Pause zwischen den Wellen). "WAVE_MIN_SECONDS": 5, "DURATION_MAX_SECONDS": 6 * 60 * 60, # Spielraum für Uhren- und Netzwerkabweichungen beim Vergleich mit der Token-Zeit. "DURATION_TOLERANCE_SECONDS": 30, # Ein Run-Token muss innerhalb dieser Zeit eingelöst werden. "RUN_TOKEN_MAX_AGE_SECONDS": 6 * 60 * 60 + 30 * 60, } logger = logging.getLogger(__name__) def resolve_project_path(value: str | os.PathLike[str]) -> Path: """Löst ``value`` relativ zum Projektordner auf, absolute Pfade bleiben unverändert. Das aktuelle Arbeitsverzeichnis wird bewusst ignoriert: Unter Passenger ist es nicht garantiert der Projektordner, und eine Datenbank „irgendwo“ wäre ein stiller Fehler. """ path = Path(value).expanduser() return path if path.is_absolute() else PROJECT_ROOT / path def env_int(name: str, default: int, minimum: int = 0) -> int: """Liest eine ganzzahlige Umgebungsvariable mit Standardwert und Untergrenze. Raises: ValueError: Wenn der Wert keine ganze Zahl ist oder unter ``minimum`` liegt. Lieber beim Start laut scheitern als mit einer kaputten Konfiguration laufen. """ raw = os.environ.get(name, "").strip() if not raw: return default try: value = int(raw) except ValueError as exc: raise ValueError(f"Umgebungsvariable {name} muss eine ganze Zahl sein: {raw!r}") from exc if value < minimum: raise ValueError(f"Umgebungsvariable {name} muss mindestens {minimum} sein: {value}") return value def env_bool(name: str, default: bool) -> bool: """Liest eine boolesche Umgebungsvariable (``1``, ``true``, ``yes``, ``on``, ``ja``).""" raw = os.environ.get(name, "").strip().lower() if not raw: return default return raw in {"1", "true", "yes", "on", "ja"} def load_or_create_secret_key(path: Path = SECRET_KEY_FILE) -> str: """Liefert einen dauerhaft gespeicherten Zufallsschlüssel und legt ihn bei Bedarf an. Warum eine Datei statt eines Zufallswerts pro Start: Passenger betreibt mehrere Prozesse parallel. Ein Run-Token, das Prozess A signiert, muss Prozess B prüfen können – dafür brauchen alle Prozesse denselben Schlüssel. """ existing = _read_secret(path) if existing: return existing path.parent.mkdir(parents=True, exist_ok=True) key = secrets.token_hex(32) try: # O_EXCL macht das Anlegen atomar: Starten zwei Prozesse gleichzeitig, gewinnt einer. fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) except FileExistsError: return _wait_for_secret(path) with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(key) return key def _read_secret(path: Path) -> str: try: return path.read_text(encoding="utf-8").strip() except FileNotFoundError: return "" def _wait_for_secret(path: Path, attempts: int = 20, delay_s: float = 0.05) -> str: """Wartet kurz, bis ein parallel startender Prozess den Schlüssel fertig geschrieben hat.""" for _ in range(attempts): key = _read_secret(path) if key: return key time.sleep(delay_s) raise RuntimeError(f"Schlüsseldatei {path} existiert, ist aber leer.") def load_config(overrides: Mapping[str, Any] | None = None) -> dict[str, Any]: """Baut die Flask-Konfiguration aus Umgebungsvariablen und optionalen Overrides. Args: overrides: Werte mit Vorrang vor den Umgebungsvariablen (v. a. für Tests). Returns: Ein Dictionary für ``app.config.from_mapping``. """ config: dict[str, Any] = { "DATABASE_PATH": str( resolve_project_path(os.environ.get("GLORY_DB_PATH") or DEFAULT_DB_PATH) ), "TRUST_PROXY": env_bool("GLORY_TRUST_PROXY", default=False), "RATE_LIMIT_MAX_SUBMISSIONS": env_int("GLORY_RATE_LIMIT_MAX", 5, minimum=1), "RATE_LIMIT_WINDOW_SECONDS": env_int("GLORY_RATE_LIMIT_WINDOW", 600, minimum=1), "NAME_BLOCKLIST_PATH": str( resolve_project_path(os.environ.get("GLORY_NAME_BLOCKLIST") or DEFAULT_BLOCKLIST_PATH) ), "MAX_CONTENT_LENGTH": MAX_CONTENT_LENGTH_BYTES, # Ohne SECRET_KEY bleibt der Wert leer; ensure_secret_key() ergänzt ihn bei Bedarf. "SECRET_KEY": os.environ.get("SECRET_KEY", "").strip() or None, **GAME_RULE_DEFAULTS, } if overrides: config.update(overrides) return config def ensure_secret_key(config: MutableMapping[str, Any]) -> str: """Liefert den SECRET_KEY und legt ``instance/secret_key`` erst bei Bedarf an. Warum erst bei Bedarf: Befehle im cPanel-Terminal (z. B. ``flask … scores list``) sehen die in cPanel gesetzten Umgebungsvariablen nicht. Sie brauchen den Schlüssel aber auch nicht und sollen deshalb keine überflüssige Schlüsseldatei anlegen. """ key = config.get("SECRET_KEY") if key: return str(key) key = load_or_create_secret_key(SECRET_KEY_FILE) config["SECRET_KEY"] = key logger.warning( "SECRET_KEY ist nicht gesetzt – verwende den automatisch erzeugten Schlüssel aus %s. " "Für den Produktivbetrieb bitte SECRET_KEY als Umgebungsvariable setzen.", SECRET_KEY_FILE, ) return key