Files
2026-09-24 17:57:54 +02:00

178 lines
7.1 KiB
Python
Raw Permalink 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.
"""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