154 lines
5.7 KiB
Python
154 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import mimetypes
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import quote, unquote, urlparse, parse_qs
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
STATIC_DIR = Path(__file__).with_name("static")
|
|
|
|
|
|
def build_game_url(query: dict[str, list[str]]) -> str:
|
|
raw_url = _first(query, "url")
|
|
if raw_url:
|
|
return _validate_http_url(raw_url)
|
|
|
|
base_url = _first(query, "base_url")
|
|
game_id = _first(query, "game_id")
|
|
if not base_url or not game_id:
|
|
raise ValueError("provide either url or both base_url and game_id")
|
|
|
|
base = _validate_http_url(base_url).rstrip("/")
|
|
safe_game_id = quote(game_id.strip("/"), safe="")
|
|
return f"{base}/games/{safe_game_id}"
|
|
|
|
|
|
class ReplayRequestHandler(BaseHTTPRequestHandler):
|
|
server_version = "TexasHoldemReplay/0.1"
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
if parsed.path == "/api/health":
|
|
self._json({"ok": True, "service": "texas-holdem-replay"})
|
|
return
|
|
if parsed.path == "/api/fetch-game":
|
|
self._handle_fetch_game(parsed.query)
|
|
return
|
|
self._serve_static(parsed.path)
|
|
|
|
def do_OPTIONS(self) -> None:
|
|
self.send_response(HTTPStatus.NO_CONTENT)
|
|
self._cors_headers()
|
|
self.end_headers()
|
|
|
|
def log_message(self, format: str, *args: Any) -> None:
|
|
return
|
|
|
|
def _handle_fetch_game(self, raw_query: str) -> None:
|
|
query = parse_qs(raw_query)
|
|
try:
|
|
target = build_game_url(query)
|
|
timeout = float(_first(query, "timeout") or 8)
|
|
request = Request(target, headers={"Accept": "application/json"})
|
|
with urlopen(request, timeout=max(1, min(timeout, 30))) as response:
|
|
payload = response.read()
|
|
content_type = response.headers.get("Content-Type", "")
|
|
try:
|
|
data = json.loads(payload.decode("utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("target did not return valid JSON") from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError("target JSON must be an object")
|
|
self._json({"source": target, "content_type": content_type, "game": data})
|
|
except HTTPError as exc:
|
|
body = exc.read().decode("utf-8", errors="replace")[:600]
|
|
self._json(
|
|
{"error": f"upstream returned HTTP {exc.code}", "detail": body},
|
|
HTTPStatus.BAD_GATEWAY,
|
|
)
|
|
except (URLError, TimeoutError) as exc:
|
|
self._json({"error": "failed to reach upstream", "detail": str(exc)}, HTTPStatus.BAD_GATEWAY)
|
|
except ValueError as exc:
|
|
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
|
|
|
def _serve_static(self, raw_path: str) -> None:
|
|
relative = unquote(raw_path.lstrip("/")) or "index.html"
|
|
if relative.endswith("/"):
|
|
relative += "index.html"
|
|
candidate = (STATIC_DIR / relative).resolve()
|
|
root = STATIC_DIR.resolve()
|
|
if root not in candidate.parents and candidate != root:
|
|
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
|
return
|
|
if not candidate.is_file():
|
|
candidate = STATIC_DIR / "index.html"
|
|
body = candidate.read_bytes()
|
|
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
|
|
self.send_response(HTTPStatus.OK)
|
|
self._cors_headers()
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Cache-Control", "no-store" if candidate.name == "index.html" else "public, max-age=60")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self._cors_headers()
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _cors_headers(self) -> None:
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
|
def create_server(host: str, port: int) -> ThreadingHTTPServer:
|
|
return ThreadingHTTPServer((host, port), ReplayRequestHandler)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Run the standalone Texas Hold X replay web viewer.")
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", default=8088, type=int)
|
|
args = parser.parse_args()
|
|
|
|
server = create_server(args.host, args.port)
|
|
print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
|
values = query.get(key)
|
|
if not values:
|
|
return None
|
|
value = values[0].strip()
|
|
return value or None
|
|
|
|
|
|
def _validate_http_url(value: str) -> str:
|
|
parsed = urlparse(value.strip())
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("url must be an absolute http(s) URL")
|
|
return value.strip()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|