slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
"""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