Files
slop-game-opus5.5/app/api.py
T
2026-09-24 17:57:54 +02:00

169 lines
5.9 KiB
Python

"""JSON-API: Highscores lesen/einreichen und Run-Tokens ausgeben.
Ablauf einer Einreichung:
1. Beim Spielstart holt das Frontend per ``POST /api/runs`` ein signiertes Run-Token.
2. Nach dem Game Over schickt es Name, Punkte, Welle, Spieldauer und das Token.
3. Der Server prüft Rate-Limit, Format, Token (Signatur, Alter, Einmaligkeit) und
Plausibilität, bevor er speichert.
"""
from __future__ import annotations
import hashlib
import hmac
import time
import uuid
from datetime import datetime, timezone
from flask import Blueprint, Response, current_app, jsonify, request
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from .config import ensure_secret_key
from .models import (
consume_run_token,
get_db,
immediate_transaction,
insert_score,
rank_of,
register_submission_attempt,
top_scores,
)
from .validation import (
PlausibilityRules,
SubmissionError,
check_plausibility,
is_name_blocked,
load_blocklist,
parse_submission,
)
bp = Blueprint("api", __name__, url_prefix="/api")
#: Salt trennt Run-Tokens von anderen Signaturen, die denselben SECRET_KEY nutzen könnten.
RUN_TOKEN_SALT = "glory-defense.run-token.v1" # noqa: S105 (Salt, kein Passwort)
def _now() -> float:
"""Aktuelle Unix-Zeit; eigene Funktion, damit Tests die Uhr gezielt verstellen können."""
return time.time()
def _serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(ensure_secret_key(current_app.config), salt=RUN_TOKEN_SALT)
def _client_hash() -> str:
"""HMAC der Client-IP: genug, um Clients zu unterscheiden, ohne IPs zu speichern."""
address = request.remote_addr or "unknown"
key = ensure_secret_key(current_app.config).encode("utf-8")
return hmac.new(key, address.encode("utf-8"), hashlib.sha256).hexdigest()
def error_response(status: int, code: str, message: str, **extra: object) -> tuple[Response, int]:
"""Einheitliches Fehlerformat: ``{"error": code, "message": text, ...}``."""
return jsonify({"error": code, "message": message, **extra}), status
def _no_store(response: Response) -> Response:
# Highscores ändern sich laufend; Browser und Proxies sollen sie nicht zwischenspeichern.
response.headers["Cache-Control"] = "no-store"
return response
@bp.post("/runs")
def start_run() -> tuple[Response, int]:
"""Gibt ein signiertes Run-Token für eine neue Spielrunde aus."""
token = _serializer().dumps({"rid": uuid.uuid4().hex})
return _no_store(jsonify({"run_token": token})), 201
@bp.get("/highscores")
def list_highscores() -> Response:
"""Liefert die Top-N-Highscores."""
scores = top_scores(get_db(), current_app.config["HIGHSCORE_TOP_N"])
return _no_store(jsonify({"highscores": scores}))
@bp.post("/highscores")
def submit_highscore() -> tuple[Response, int]:
"""Nimmt einen neuen Highscore entgegen (Details siehe Moduldokumentation)."""
config = current_app.config
db = get_db()
now = _now()
# Das Rate-Limit greift vor allem anderen, damit auch fehlerhafte Anfragen zählen.
allowed, retry_after = register_submission_attempt(
db,
_client_hash(),
now,
window_s=config["RATE_LIMIT_WINDOW_SECONDS"],
max_attempts=config["RATE_LIMIT_MAX_SUBMISSIONS"],
)
if not allowed:
response, status = error_response(
429,
"rate_limited",
"Zu viele Einreichungen. Bitte später erneut versuchen.",
retry_after=retry_after,
)
response.headers["Retry-After"] = str(retry_after)
return response, status
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return error_response(400, "invalid_json", "Erwartet wird ein JSON-Objekt.")
rules = PlausibilityRules.from_config(config)
try:
submission = parse_submission(payload, rules)
except SubmissionError as exc:
return error_response(400, "validation_failed", "Ungültige Eingaben.", fields=exc.fields)
# Optionale Sperrliste des Betreibers (z. B. Namen realer Personen, Beleidigungen).
if is_name_blocked(submission.name, load_blocklist(config["NAME_BLOCKLIST_PATH"])):
return error_response(
400,
"validation_failed",
"Ungültige Eingaben.",
fields={"name": "Dieser Name ist nicht zulässig."},
)
try:
token_data, issued_at = _serializer().loads(
submission.run_token,
max_age=config["RUN_TOKEN_MAX_AGE_SECONDS"],
return_timestamp=True,
)
except SignatureExpired:
return error_response(422, "run_token_expired", "Das Run-Token ist abgelaufen.")
except BadSignature:
return error_response(422, "run_token_invalid", "Das Run-Token ist ungültig.")
run_id = token_data.get("rid") if isinstance(token_data, dict) else None
if not isinstance(run_id, str):
return error_response(422, "run_token_invalid", "Das Run-Token ist ungültig.")
elapsed_s = (datetime.now(timezone.utc) - issued_at).total_seconds()
reason = check_plausibility(submission, elapsed_s, rules)
if reason is not None:
return error_response(422, "implausible_score", reason)
# Token einlösen und speichern gehören zusammen: Scheitert das Speichern,
# darf das Token nicht verbraucht sein.
with immediate_transaction(db):
if not consume_run_token(db, run_id, now, config["RUN_TOKEN_MAX_AGE_SECONDS"]):
return error_response(
409, "run_token_used", "Für diese Runde wurde bereits ein Highscore gespeichert."
)
score_id = insert_score(
db, submission.name, submission.score, submission.wave, submission.duration_s
)
body = {
"id": score_id,
"rank": rank_of(db, score_id),
"name": submission.name,
"highscores": top_scores(db, config["HIGHSCORE_TOP_N"]),
}
return _no_store(jsonify(body)), 201