#!/usr/bin/env python3 """Audit public HTTP requests exposed by the HLL Vietnam application.""" from __future__ import annotations import argparse import json import re import sys import time from dataclasses import asdict, dataclass from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen DEFAULT_BASE_URL = "http://127.0.0.1:8000" DEFAULT_TIMEOUT_SECONDS = 25.0 KNOWN_MATCH_ID = "comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare" KNOWN_PLAYER_SEARCH = "Medu" RANKING_METRICS = ( "kills", "deaths", "teamkills", "matches_considered", "kd_ratio", "kills_per_match", ) RANKING_SCOPES = ("all", "comunidad-hispana-01", "comunidad-hispana-02") HISTORICAL_SCOPES = ("all-servers", "comunidad-hispana-01", "comunidad-hispana-02") HISTORICAL_LEGACY_METRICS = ("kills", "deaths", "support", "matches_over_100_kills") PLAYER_EVENT_VIEWS = ("most-killed", "death-by", "duels", "weapon-kills", "teamkills") @dataclass(frozen=True) class ProbeSpec: id: str kind: str context: str method: str path: str parameters: str def main() -> int: parser = argparse.ArgumentParser( description="Measure public HLL Vietnam frontend/backend request surfaces." ) parser.add_argument( "--base-url", default=DEFAULT_BASE_URL, help=f"Base URL to audit. Default: {DEFAULT_BASE_URL}", ) parser.add_argument( "--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS, help=f"Per-request timeout in seconds. Default: {DEFAULT_TIMEOUT_SECONDS}", ) parser.add_argument( "--output", default=None, help="JSON output path. Default: tmp/public_request_audit.json.", ) parser.add_argument( "--repo-root", default=None, help="Repository root for static discovery metadata. Default: auto/cwd.", ) parser.add_argument( "--max-probes", type=int, default=0, help="Optional cap for smoke runs. 0 means all probes.", ) parser.add_argument( "--filter", default=None, help="Run only probes whose id, kind, context, path or parameters contain this text.", ) parser.add_argument( "--player-id", default=None, help="Add player-dependent probes for this player id before applying --filter.", ) args = parser.parse_args() base_url = normalize_base_url(args.base_url) repo_root = resolve_repo_root(args.repo_root) output_path = resolve_output_path(args.output) specs = build_probe_specs() if args.player_id: specs.extend(build_player_dependent_specs(args.player_id)) if args.filter: specs = filter_probe_specs(specs, args.filter) if args.max_probes and args.max_probes > 0: specs = specs[: args.max_probes] results: list[dict[str, Any]] = [] dynamic_player_id: str | None = None index = 0 while index < len(specs): spec = specs[index] result = run_probe(base_url=base_url, spec=spec, timeout_seconds=args.timeout) results.append(result) print_result_row(result) if spec.id == "stats-player-search-medu" and dynamic_player_id is None: dynamic_player_id = extract_first_player_id(result.get("json")) if dynamic_player_id: specs.extend(build_player_dependent_specs(dynamic_player_id)) index += 1 payload = { "audit": { "base_url": base_url, "timeout_seconds": args.timeout, "generated_at_epoch": time.time(), "probe_count": len(results), }, "summary": summarize_results(results), "discovery": discover_static_requests(repo_root), "results": strip_json_payloads(results), } output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") print() print_summary(payload["summary"]) print(f"Saved JSON results to {output_path}") return 0 def build_probe_specs() -> list[ProbeSpec]: specs: list[ProbeSpec] = [] seen: set[str] = set() def add( id_: str, path: str, *, kind: str = "backend-api", context: str = "backend", parameters: str = "", ) -> None: if id_ in seen: return seen.add(id_) specs.append( ProbeSpec( id=id_, kind=kind, context=context, method="GET", path=path, parameters=parameters, ) ) add("health", "/health", context="health", parameters="none") add("api-community", "/api/community", context="home", parameters="none") add("api-trailer", "/api/trailer", context="home", parameters="none") add("api-discord", "/api/discord", context="home", parameters="none") add("api-servers", "/api/servers", context="home", parameters="none") add("api-servers-latest", "/api/servers/latest", context="home", parameters="none") add("api-servers-history", "/api/servers/history?limit=20", context="server-history", parameters="limit=20") for server in ("comunidad-hispana-01", "comunidad-hispana-02"): add( f"api-servers-{server}-history", f"/api/servers/{server}/history?limit=20", context="server-history", parameters="server path, limit=20", ) for timeframe in ("weekly", "monthly", "annual"): for server in RANKING_SCOPES: for metric in RANKING_METRICS: params = { "timeframe": timeframe, "server_id": server, "metric": metric, "limit": "20", } if timeframe == "annual": params["year"] = "2026" query = "&".join(f"{key}={quote(value)}" for key, value in params.items()) add( f"ranking-{timeframe}-{server}-{metric}", f"/api/ranking?{query}", context="ranking.html", parameters="timeframe, server_id, metric, limit, year for annual", ) add( "ranking-weekly-all-servers-alias", "/api/ranking?timeframe=weekly&server_id=all-servers&metric=kills&limit=20", context="ranking.html", parameters="server_id=all-servers alias", ) add( "stats-player-search-medu", f"/api/stats/players/search?q={quote(KNOWN_PLAYER_SEARCH)}&server_id=all&limit=10", context="stats.html", parameters="q=Medu, server_id=all, limit=10", ) add( "stats-annual-ranking", "/api/stats/rankings/annual?year=2026&server_id=all&metric=kills&limit=20", context="stats.html", parameters="year=2026, server_id=all, metric=kills, limit=20", ) for server in ("comunidad-hispana-01", "comunidad-hispana-02"): add( f"current-match-{server}", f"/api/current-match?server={server}", context="partida-actual.html", parameters="server", ) add( f"current-match-kills-{server}", f"/api/current-match/kills?server={server}&limit=30", context="partida-actual.html", parameters="server, limit=30", ) add( f"current-match-players-{server}", f"/api/current-match/players?server={server}", context="partida-actual.html", parameters="server", ) for server in HISTORICAL_SCOPES: add( f"historical-server-summary-{server}", f"/api/historical/server-summary?server={server}", context="historico.html legacy", parameters="server", ) add( f"historical-recent-matches-{server}", f"/api/historical/recent-matches?server={server}&limit=20", context="historico.html legacy", parameters="server, limit=20", ) add( f"snapshot-server-summary-{server}", f"/api/historical/snapshots/server-summary?server={server}", context="historico.html", parameters="server", ) add( f"snapshot-recent-matches-{server}", f"/api/historical/snapshots/recent-matches?server={server}&limit=100", context="historico.html", parameters="server, limit=100", ) add( f"weekly-top-kills-{server}", f"/api/historical/weekly-top-kills?server={server}&limit=10", context="historical legacy API", parameters="server, limit=10", ) for timeframe in ("weekly", "monthly"): for metric in HISTORICAL_LEGACY_METRICS: add( f"historical-leaderboard-{timeframe}-{server}-{metric}", ( "/api/historical/leaderboard?" f"server={server}&timeframe={timeframe}&metric={metric}&limit=10" ), context="historical legacy API", parameters="server, timeframe, metric, limit=10", ) add( f"snapshot-leaderboard-{timeframe}-{server}-{metric}", ( "/api/historical/snapshots/leaderboard?" f"server={server}&timeframe={timeframe}&metric={metric}&limit=10" ), context="historico.html", parameters="server, timeframe, metric, limit=10", ) add( f"historical-weekly-leaderboard-{server}", f"/api/historical/weekly-leaderboard?server={server}&metric=kills&limit=10", context="historical legacy API", parameters="server, metric=kills, limit=10", ) add( f"historical-monthly-leaderboard-{server}", f"/api/historical/monthly-leaderboard?server={server}&metric=kills&limit=10", context="historical legacy API", parameters="server, metric=kills, limit=10", ) add( f"snapshot-weekly-leaderboard-{server}", f"/api/historical/snapshots/weekly-leaderboard?server={server}&metric=kills&limit=10", context="historico.html", parameters="server, metric=kills, limit=10", ) add( f"snapshot-monthly-leaderboard-{server}", f"/api/historical/snapshots/monthly-leaderboard?server={server}&metric=kills&limit=10", context="historico.html", parameters="server, metric=kills, limit=10", ) add( f"monthly-mvp-{server}", f"/api/historical/monthly-mvp?server={server}&limit=10", context="historical public API", parameters="server, limit=10", ) add( f"monthly-mvp-v2-{server}", f"/api/historical/monthly-mvp-v2?server={server}&limit=10", context="historical public API", parameters="server, limit=10", ) add( f"snapshot-monthly-mvp-{server}", f"/api/historical/snapshots/monthly-mvp?server={server}&limit=10", context="historical snapshots", parameters="server, limit=10", ) add( f"snapshot-monthly-mvp-v2-{server}", f"/api/historical/snapshots/monthly-mvp-v2?server={server}&limit=10", context="historical snapshots", parameters="server, limit=10", ) for view in PLAYER_EVENT_VIEWS: add( f"player-events-{server}-{view}", f"/api/historical/player-events?server={server}&view={view}&limit=10", context="historical public API", parameters="server, view, limit=10", ) add( f"snapshot-player-events-{server}-{view}", f"/api/historical/snapshots/player-events?server={server}&view={view}&limit=10", context="historical snapshots", parameters="server, view, limit=10", ) add( "historical-match-detail-known", ( "/api/historical/matches/detail?" "server=comunidad-hispana-01&match=" f"{quote(KNOWN_MATCH_ID, safe='')}" ), context="historico-partida.html", parameters="server, match", ) add( "elo-mmr-leaderboard-paused", "/api/historical/elo-mmr/leaderboard?server=all-servers&limit=10", context="paused public API", parameters="server, limit=10", ) return specs def build_player_dependent_specs(player_id: str) -> list[ProbeSpec]: quoted_player = quote(player_id, safe="") return [ ProbeSpec( id="stats-player-profile-weekly", kind="backend-api", context="stats.html", method="GET", path=f"/api/stats/players/{quoted_player}?timeframe=weekly&server_id=all", parameters="player_id from search, timeframe=weekly, server_id=all", ), ProbeSpec( id="stats-player-profile-monthly", kind="backend-api", context="stats.html", method="GET", path=f"/api/stats/players/{quoted_player}?timeframe=monthly&server_id=all", parameters="player_id from search, timeframe=monthly, server_id=all", ), ProbeSpec( id="historical-player-profile", kind="backend-api", context="historical public API", method="GET", path=f"/api/historical/player-profile?player={quoted_player}", parameters="player from search", ), ProbeSpec( id="elo-mmr-player-paused", kind="backend-api", context="paused public API", method="GET", path=f"/api/historical/elo-mmr/player?server=all-servers&player={quoted_player}", parameters="player from search, server=all-servers", ), ] def filter_probe_specs(specs: list[ProbeSpec], raw_filter: str) -> list[ProbeSpec]: needle = raw_filter.strip().casefold() if not needle: return specs return [ spec for spec in specs if needle in " ".join( ( spec.id, spec.kind, spec.context, spec.path, spec.parameters, ) ).casefold() ] def run_probe(*, base_url: str, spec: ProbeSpec, timeout_seconds: float) -> dict[str, Any]: url = f"{base_url}{spec.path}" started = time.perf_counter() status_code: int | None = None body = b"" error: str | None = None headers: dict[str, str] = {} try: request = Request( url, headers={ "Accept": "application/json", "User-Agent": "HLL-Vietnam-Public-Request-Audit/1.0", }, method=spec.method, ) with urlopen(request, timeout=timeout_seconds) as response: status_code = int(response.status) headers = {key.lower(): value for key, value in response.headers.items()} body = response.read() except HTTPError as exc: status_code = int(exc.code) headers = {key.lower(): value for key, value in exc.headers.items()} body = exc.read() error = f"HTTPError: {exc.code}" except URLError as exc: error = f"URLError: {exc.reason}" except TimeoutError: error = "TimeoutError" except Exception as exc: # noqa: BLE001 - audit must continue error = f"{type(exc).__name__}: {exc}" elapsed_ms = round((time.perf_counter() - started) * 1000, 2) parsed_json = parse_json_body(body) summary = summarize_json(parsed_json) severity = classify_result( status_code=status_code, elapsed_ms=elapsed_ms, error=error, fallback_used=summary.get("fallback_used"), snapshot_status=summary.get("snapshot_status"), ) return { **asdict(spec), "url": url, "status_code": status_code, "elapsed_ms": elapsed_ms, "response_size_bytes": len(body), "content_type": headers.get("content-type"), "json_status": summary.get("json_status"), "data_source": summary.get("data_source"), "fallback_used": summary.get("fallback_used"), "fallback_reason": summary.get("fallback_reason"), "snapshot_status": summary.get("snapshot_status"), "item_count": summary.get("item_count"), "found": summary.get("found"), "severity": severity, "error": error, "json": parsed_json, } def parse_json_body(body: bytes) -> Any: if not body: return None try: return json.loads(body.decode("utf-8")) except Exception: return None def summarize_json(payload: Any) -> dict[str, Any]: if not isinstance(payload, dict): return {} data = payload.get("data") if isinstance(payload.get("data"), dict) else {} source_payload = data.get("source") if isinstance(data.get("source"), dict) else {} items = data.get("items") if isinstance(items, list): item_count = len(items) elif isinstance(data.get("item"), dict): item_count = 1 elif isinstance(data.get("profile"), dict): item_count = 1 else: item_count = None return { "json_status": payload.get("status"), "data_source": data.get("source") if isinstance(data.get("source"), str) else source_payload.get("read_model") or source_payload.get("primary_source"), "fallback_used": data.get("fallback_used") if "fallback_used" in data else source_payload.get("fallback_used"), "fallback_reason": data.get("fallback_reason") if "fallback_reason" in data else source_payload.get("fallback_reason"), "snapshot_status": data.get("snapshot_status"), "item_count": item_count, "found": data.get("found"), } def classify_result( *, status_code: int | None, elapsed_ms: float, error: str | None, fallback_used: Any, snapshot_status: Any, ) -> str: if error or status_code is None or status_code >= 500 or elapsed_ms >= 10000: return "CRITICAL" if status_code >= 400 or elapsed_ms >= 2500 or fallback_used is True: return "WARNING" if str(snapshot_status or "").lower() == "missing": return "WARNING" return "OK" def extract_first_player_id(payload: Any) -> str | None: if not isinstance(payload, dict): return None data = payload.get("data") if not isinstance(data, dict): return None items = data.get("items") if not isinstance(items, list): return None for item in items: if isinstance(item, dict) and str(item.get("player_id") or "").strip(): return str(item["player_id"]).strip() return None def discover_static_requests(repo_root: Path) -> dict[str, Any]: backend_paths: set[str] = set() frontend_literals: set[str] = set() fetch_occurrences: list[dict[str, Any]] = [] localhost_refs: list[dict[str, Any]] = [] routes_path = repo_root / "backend" / "app" / "routes.py" if routes_path.exists(): text = routes_path.read_text(encoding="utf-8") for value in re.findall(r"['\"](/(?:api/[^'\"]+|health))['\"]", text): backend_paths.add(value) frontend_root = repo_root / "frontend" if frontend_root.exists(): for path in sorted(frontend_root.rglob("*")): if path.suffix.lower() not in {".html", ".js"}: continue text = path.read_text(encoding="utf-8", errors="replace") for value in re.findall(r"/api/[A-Za-z0-9_./?=&%${}()+:,\\-]+|/health", text): frontend_literals.add(value) for line_number, line in enumerate(text.splitlines(), start=1): if "fetch(" in line: fetch_occurrences.append( { "file": str(path.relative_to(repo_root)), "line": line_number, "text": line.strip(), } ) if "127.0.0.1" in line or "localhost" in line: localhost_refs.append( { "file": str(path.relative_to(repo_root)), "line": line_number, "text": line.strip(), } ) return { "repo_root": str(repo_root), "backend_route_paths": sorted(backend_paths), "frontend_api_literals": sorted(frontend_literals), "frontend_fetch_occurrences": fetch_occurrences, "frontend_localhost_references": localhost_refs, } def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]: severity_counts = {"OK": 0, "WARNING": 0, "CRITICAL": 0} status_counts: dict[str, int] = {} launched = 0 for result in results: launched += 1 severity = str(result.get("severity") or "WARNING") severity_counts[severity] = severity_counts.get(severity, 0) + 1 status_key = str(result.get("status_code")) status_counts[status_key] = status_counts.get(status_key, 0) + 1 slowest = sorted( ( { "id": result.get("id"), "path": result.get("path"), "elapsed_ms": result.get("elapsed_ms"), "status_code": result.get("status_code"), "severity": result.get("severity"), } for result in results ), key=lambda item: float(item.get("elapsed_ms") or 0), reverse=True, )[:10] return { "launched": launched, "severity_counts": severity_counts, "status_counts": status_counts, "slowest": slowest, } def strip_json_payloads(results: list[dict[str, Any]]) -> list[dict[str, Any]]: stripped = [] for result in results: item = dict(result) item.pop("json", None) stripped.append(item) return stripped def print_result_row(result: dict[str, Any]) -> None: status = result.get("status_code") elapsed = result.get("elapsed_ms") severity = result.get("severity") size = result.get("response_size_bytes") fallback = result.get("fallback_used") print( f"{severity:8} {str(status):>4} {elapsed:>9.2f} ms {size:>9} B " f"fallback={str(fallback):<5} {result['id']}" ) def print_summary(summary: dict[str, Any]) -> None: print("Summary") print(f" launched: {summary['launched']}") print(f" OK: {summary['severity_counts'].get('OK', 0)}") print(f" WARNING: {summary['severity_counts'].get('WARNING', 0)}") print(f" CRITICAL: {summary['severity_counts'].get('CRITICAL', 0)}") print(" slowest:") for item in summary.get("slowest", []): print( " " f"{item.get('elapsed_ms')} ms {item.get('status_code')} " f"{item.get('severity')} {item.get('id')}" ) def normalize_base_url(value: str) -> str: return str(value or DEFAULT_BASE_URL).strip().rstrip("/") def resolve_repo_root(raw_value: str | None) -> Path: if raw_value: return Path(raw_value).resolve() cwd = Path.cwd().resolve() if (cwd / "backend" / "app" / "routes.py").exists(): return cwd script_path = Path(__file__).resolve() for candidate in (script_path.parent, *script_path.parents): if (candidate / "backend" / "app" / "routes.py").exists(): return candidate return cwd def resolve_output_path(raw_value: str | None) -> Path: if raw_value: return Path(raw_value) tmp_path = Path("tmp") / "public_request_audit.json" try: tmp_path.parent.mkdir(parents=True, exist_ok=True) return tmp_path except OSError: return Path("docs") / "public_request_audit_results.example.json" if __name__ == "__main__": sys.exit(main())