#!/usr/bin/env python3 """Zombie Room FPS — leaderboard API. Solo libreria standard: nessuna dipendenza pip, immagine minima e codice verificabile. Dietro nginx: il percorso pubblico e' /api/zombie-fps/ e nginx rimuove quel prefisso prima di inoltrare, quindi qui i percorsi sono /health, /scores, ... Endpoint GET /health stato del servizio GET /scores?limit=10&player= classifica (tutte le partite) + il tuo riassunto POST /scores {"player_id","name","score","submission_id"} Il punteggio arriva dal client ed e' quindi falsificabile: non c'e' anti-cheat. Le difese sono tetto di plausibilita', rate limit e idempotenza dei retry. """ import json import os import re import sqlite3 import threading import time import uuid from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse VERSION = "1.0.0" SERVICE = "zombie-fps-scores" DB_PATH = os.environ.get("DATABASE_PATH", "/data/scores.db") PORT = int(os.environ.get("PORT", "8791")) BIND = os.environ.get("BIND", "0.0.0.0") MAX_BODY_BYTES = int(os.environ.get("MAX_BODY_BYTES", "2048")) NAME_RE = re.compile(r"^[\w \-.]{1,16}$", re.UNICODE) # lettere (anche accentate), cifre, spazio, _ - . ID_RE = re.compile(r"^[A-Za-z0-9_\-]{8,64}$") # Il gioco assegna 100/150 per zombie: il punteggio e' sempre multiplo di 50. SCORE_MIN, SCORE_MAX, SCORE_STEP = 0, 200000, 50 NAME_MAX = 16 TOP_DEFAULT, TOP_MAX = 10, 50 # Limiti ant-abuso, configurabili per poterli testare su un'istanza dedicata. POST_PER_HOUR = int(os.environ.get("POST_PER_HOUR", "20")) POST_MIN_INTERVAL = float(os.environ.get("POST_MIN_INTERVAL", "5.0")) GET_PER_MINUTE = int(os.environ.get("GET_PER_MINUTE", "120")) SCHEMA = """ CREATE TABLE IF NOT EXISTS scores ( id TEXT PRIMARY KEY, player_id TEXT NOT NULL, submission_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL, score INTEGER NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_scores_rank ON scores(score DESC, created_at ASC); CREATE INDEX IF NOT EXISTS idx_scores_player ON scores(player_id, score DESC); """ _lock = threading.Lock() _post_by_ip: dict = {} # ip -> [timestamp, ...] _last_by_player: dict = {} # player_id -> timestamp _get_by_ip: dict = {} # ip -> [timestamp, ...] class ApiError(Exception): def __init__(self, status: int, message: str): super().__init__(message) self.status = status self.message = message def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def connect() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH, timeout=5.0) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout = 5000") conn.execute("PRAGMA journal_mode = WAL") # regge lettori e un scrittore insieme conn.execute("PRAGMA synchronous = FULL") # un invio confermato sopravvive al riavvio conn.execute("PRAGMA foreign_keys = ON") return conn def init_db() -> None: os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) conn = connect() try: conn.executescript(SCHEMA) conn.commit() finally: conn.close() def rank_of(conn: sqlite3.Connection, score: int, created_at: str) -> int: row = conn.execute( "SELECT COUNT(*) AS n FROM scores WHERE score > ? OR (score = ? AND created_at < ?)", (score, score, created_at)).fetchone() return int(row["n"]) + 1 def clamp_seconds(window: float, stamps: list) -> list: cutoff = time.monotonic() - window return [s for s in stamps if s >= cutoff] def check_rate(store: dict, key: str, window: float, limit: int) -> None: """Solo verifica: non modifica lo stato, cosi' un invio respinto per un altro motivo non consuma il budget dell'IP.""" stamps = clamp_seconds(window, store.get(key, [])) store[key] = stamps if len(stamps) >= limit: raise ApiError(429, "troppi invii, riprova piu' tardi") def record_hit(store: dict, key: str) -> None: store.setdefault(key, []).append(time.monotonic()) def validate_submission(payload: dict) -> dict: if not isinstance(payload, dict): raise ApiError(400, "corpo JSON non valido") name = payload.get("name") player_id = payload.get("player_id") submission_id = payload.get("submission_id") score = payload.get("score") if not isinstance(name, str): raise ApiError(400, "nome mancante") name = " ".join(name.split()) # niente newline, tab, spazi doppi if not NAME_RE.match(name): raise ApiError(400, "nome non valido: 1-16 caratteri, lettere cifre spazio . _ -") if not isinstance(player_id, str) or not ID_RE.match(player_id): raise ApiError(400, "player_id non valido") if not isinstance(submission_id, str): raise ApiError(400, "submission_id mancante") try: uuid.UUID(submission_id) except (ValueError, AttributeError): raise ApiError(400, "submission_id deve essere un uuid") if isinstance(score, bool) or not isinstance(score, int): raise ApiError(400, "punteggio deve essere un intero") if score < SCORE_MIN or score > SCORE_MAX or score % SCORE_STEP: raise ApiError(400, "punteggio fuori dai limiti plausibili") return {"name": name, "player_id": player_id, "submission_id": submission_id.lower(), "score": score} def submit(payload: dict, ip: str) -> tuple: data = validate_submission(payload) conn = connect() try: # L'idempotenza si verifica PRIMA dei rate limit: il retry dello stesso invio non e' # carico nuovo e non deve essere bloccato dal limite per giocatore o per IP. existing = conn.execute("SELECT * FROM scores WHERE submission_id = ?", (data["submission_id"],)).fetchone() if existing is not None: return 200, {"ok": True, "duplicate": True, "id": existing["id"], "rank": rank_of(conn, existing["score"], existing["created_at"]), "total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]} with _lock: check_rate(_post_by_ip, ip, 3600.0, POST_PER_HOUR) last = _last_by_player.get(data["player_id"]) if last is not None and time.monotonic() - last < POST_MIN_INTERVAL: raise ApiError(429, "hai appena inviato un punteggio") # Solo qui l'invio e' accettato: ora si consuma il budget. record_hit(_post_by_ip, ip) _last_by_player[data["player_id"]] = time.monotonic() row_id = str(uuid.uuid4()) # id univoco casuale della riga, generato dal server created = now_iso() try: conn.execute( "INSERT INTO scores (id, player_id, submission_id, name, score, created_at) " "VALUES (?, ?, ?, ?, ?, ?)", (row_id, data["player_id"], data["submission_id"], data["name"], data["score"], created)) conn.commit() except sqlite3.IntegrityError: # Due invii identici in parallelo: il secondo e' un duplicato, non un errore. existing = conn.execute("SELECT * FROM scores WHERE submission_id = ?", (data["submission_id"],)).fetchone() if existing is None: raise return 200, {"ok": True, "duplicate": True, "id": existing["id"], "rank": rank_of(conn, existing["score"], existing["created_at"]), "total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]} return 201, {"ok": True, "duplicate": False, "id": row_id, "rank": rank_of(conn, data["score"], created), "total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]} finally: conn.close() def leaderboard(limit: int, player_id: str | None) -> dict: conn = connect() try: total = int(conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]) rows = conn.execute( "SELECT id, player_id, name, score, created_at FROM scores " "ORDER BY score DESC, created_at ASC LIMIT ?", (limit,)).fetchall() top = [{"rank": i + 1, "id": r["id"], "player_id": r["player_id"], "name": r["name"], "score": int(r["score"]), "created_at": r["created_at"]} for i, r in enumerate(rows)] me = None if player_id: best = conn.execute( "SELECT * FROM scores WHERE player_id = ? ORDER BY score DESC, created_at ASC LIMIT 1", (player_id,)).fetchone() if best is not None: me = {"best": int(best["score"]), "name": best["name"], "created_at": best["created_at"], "rank": rank_of(conn, best["score"], best["created_at"]), "games": int(conn.execute( "SELECT COUNT(*) AS n FROM scores WHERE player_id = ?", (player_id,)).fetchone()["n"])} return {"top": top, "total": total, "limit": limit, "me": me} finally: conn.close() class Handler(BaseHTTPRequestHandler): server_version = "zombie-fps-scores/" + VERSION protocol_version = "HTTP/1.1" def log_message(self, fmt: str, *args) -> None: print("[%s] %s %s" % (now_iso(), self.address_string(), fmt % args), flush=True) def _send(self, status: int, payload: dict, extra=None) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") for key, value in (extra or {}).items(): self.send_header(key, value) self.end_headers() self.wfile.write(body) def _fail(self, error: ApiError) -> None: extra = {"Retry-After": "60"} if error.status == 429 else None self._send(error.status, {"error": error.message}, extra) def _client_ip(self) -> str: # nginx e' l'unico ingresso: usa l'IP reale inoltrato, con ripiego sul peer. forwarded = self.headers.get("X-Real-IP") or self.headers.get("X-Forwarded-For", "") return forwarded.split(",")[0].strip() or self.client_address[0] def _read_json(self) -> dict: length = self.headers.get("Content-Length") try: size = int(length) if length is not None else 0 except ValueError: raise ApiError(400, "Content-Length non valido") if size > MAX_BODY_BYTES: raise ApiError(413, "corpo troppo grande") raw = self.rfile.read(size) if size else b"" try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ApiError(400, "JSON non valido") def do_GET(self) -> None: parsed = urlparse(self.path) path, query = parsed.path.rstrip("/") or "/", parse_qs(parsed.query) try: if path == "/health": conn = connect() try: rows = int(conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]) finally: conn.close() self._send(200, {"ok": True, "service": SERVICE, "version": VERSION, "rows": rows}) return if path == "/scores": with _lock: check_rate(_get_by_ip, self._client_ip(), 60.0, GET_PER_MINUTE) try: limit = int(query.get("limit", [str(TOP_DEFAULT)])[0]) except ValueError: raise ApiError(400, "limit non valido") limit = max(1, min(TOP_MAX, limit)) player = query.get("player", [None])[0] if player and not ID_RE.match(player): raise ApiError(400, "player non valido") payload = leaderboard(limit, player) with _lock: record_hit(_get_by_ip, self._client_ip()) # solo una lettura servita self._send(200, payload) return raise ApiError(404, "percorso non trovato") except ApiError as error: self._fail(error) except Exception as error: # qualunque altro errore: 500 JSON print("ERRORE /%s: %r" % (path, error), flush=True) self._send(500, {"error": "errore interno"}) def do_POST(self) -> None: path = urlparse(self.path).path.rstrip("/") or "/" try: if path != "/scores": raise ApiError(404, "percorso non trovato") if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json": raise ApiError(415, "Content-Type deve essere application/json") status, payload = submit(self._read_json(), self._client_ip()) self._send(status, payload) except ApiError as error: self._fail(error) except Exception as error: print("ERRORE POST %s: %r" % (path, error), flush=True) self._send(500, {"error": "errore interno"}) def do_HEAD(self) -> None: self.do_GET() def do_OPTIONS(self) -> None: # solo per lo sviluppo locale origin = self.headers.get("Origin", "") allowed = os.environ.get("DEV_CORS_ORIGINS", "") if origin and origin in [o.strip() for o in allowed.split(",") if o.strip()]: self._send(204, {}, {"Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": "600"}) else: self._send(204, {}) def main() -> None: init_db() server = ThreadingHTTPServer((BIND, PORT), Handler) server.daemon_threads = True print("%s %s in ascolto su %s:%d (db %s)" % (SERVICE, VERSION, BIND, PORT, DB_PATH), flush=True) server.serve_forever() if __name__ == "__main__": main()