slop game oder so
This commit is contained in:
+177
@@ -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
|
||||
Reference in New Issue
Block a user