169 lines
5.9 KiB
Python
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
|