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