|
|
|
@@ -0,0 +1,337 @@
|
|
|
|
|
#!/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=<id> 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()
|