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