"""Small, bounded same-origin gateway for the alphaBell Arcade Space.""" import asyncio from collections import defaultdict, deque from contextlib import asynccontextmanager from dataclasses import dataclass import hashlib import hmac from http.cookies import SimpleCookie import ipaddress import os import re import secrets import time from urllib.parse import parse_qsl, urlsplit from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, Response import httpx MAX_DOCUMENT = 1_500_000 MAX_ASSET = 500_000 EDGE_COOKIE = "arcade_edge" ASSETS = {"/app.js", "/styles.css", "/favicon.svg"} ASSET_ROUTES = re.compile(r"^/(?:preview/[A-Za-z0-9_-]{1,80}\.png|fonts/[A-Za-z0-9][A-Za-z0-9_-]{0,100}\.(?:woff2|txt))$") ROUTES = re.compile(r"^/(?:g/[A-Za-z0-9_-]{1,80}|play/[A-Za-z0-9_-]{1,80}|download/[A-Za-z0-9_-]{1,80}|api/(?:config|games(?:/[A-Za-z0-9_-]{1,80})?|jobs(?:/[A-Za-z0-9_-]{1,80})?)|health)?$") @dataclass(frozen=True) class Settings: origin: str = "https://alphabell.com/arcade" edge_secret: str = "" space_host: str = "" per_hour: int = 3 daily_cap: int = 30 def __post_init__(self): parsed = urlsplit(self.origin) if parsed.scheme != "https" or parsed.netloc != "alphabell.com" or parsed.path.rstrip("/") != "/arcade" or parsed.query or parsed.fragment: raise ValueError("The gateway target must be the alphaBell Arcade application") if self.space_host and not re.fullmatch(r"[a-z0-9-]+\.hf\.space", self.space_host): raise ValueError("Invalid Space host") class Limits: """A single-worker admission limit, in addition to durable backend quotas.""" def __init__(self): self.hourly = defaultdict(deque) self.daily = deque() self.lock = asyncio.Lock() async def admit(self, cookie, client): now = time.time() async with self.lock: for key in list(self.hourly): values = self.hourly[key] while values and values[0] <= now - 3600: values.popleft() if not values: del self.hourly[key] while self.daily and self.daily[0] <= now - 86400: self.daily.popleft() if len(self.daily) >= self.daily_cap: return False keys = ("cookie:" + cookie, "client:" + client) if any(len(self.hourly[key]) >= self.per_hour for key in keys): return False for key in keys: self.hourly[key].append(now) self.daily.append(now) return True def _signature(secret, value): return hmac.new(secret.encode(), value.encode(), hashlib.sha256).hexdigest() def _edge_cookie(request, secret): value = request.cookies.get(EDGE_COOKIE, "") identity, _, signature = value.partition(".") if not re.fullmatch(r"[A-Za-z0-9_-]{32}", identity) or not hmac.compare_digest(signature, _signature(secret, "cookie\n" + identity)): identity = secrets.token_urlsafe(24) return identity, identity + "." + _signature(secret, "cookie\n" + identity) def _client(request, settings): # HF's gateway appends the address it observes. Never trust the leftmost # element, which a caller can supply. Outside Spaces use the direct peer. address = request.client.host if request.client else "unknown" forwarded = request.headers.get("x-forwarded-for", "") if settings.space_host and forwarded: address = forwarded.split(",")[-1].strip() try: address = str(ipaddress.ip_address(address)) except ValueError: address = "unknown" return _signature(settings.edge_secret, "client-ip\n" + address) def _upstream_cookie(raw): cookies = SimpleCookie() try: cookies.load(raw) except Exception: return "" item = cookies.get("arcade_owner") if item and re.fullmatch(r"[A-Za-z0-9_-]{32}\.[0-9a-f]{64}", item.value): return "arcade_owner=" + item.value return "" def _rewrite_cookie(raw): if not raw.lower().startswith("arcade_owner="): return None # Drop Domain and origin-specific Path; keep only this gateway's cookie. parts = [part.strip() for part in raw.split(";")] kept = [parts[0]] for part in parts[1:]: name = part.split("=", 1)[0].lower() if name in {"max-age", "expires"}: kept.append(part) return "; ".join(kept + ["Path=/", "HttpOnly", "Secure", "SameSite=None", "Partitioned"]) def _rewrite_location(location, origin): parsed = urlsplit(location) if parsed.scheme or parsed.netloc: if parsed.scheme != "https" or parsed.netloc != "alphabell.com": return None path = parsed.path else: path = parsed.path prefix = urlsplit(origin).path.rstrip("/") if not (path == prefix or path.startswith(prefix + "/")): return None return (path[len(prefix):] or "/") + ("?" + parsed.query if parsed.query else "") def create_app(settings=None, transport=None): settings = settings or Settings(origin=os.environ.get("ARCADE_ORIGIN", "https://alphabell.com/arcade").rstrip("/"), edge_secret=os.environ.get("ARCADE_EDGE_SECRET", ""), space_host=os.environ.get("SPACE_HOST", "")) client = httpx.AsyncClient(transport=transport, trust_env=False, follow_redirects=False, verify=True, limits=httpx.Limits(max_connections=12, max_keepalive_connections=4)) limits = Limits() limits.per_hour, limits.daily_cap = settings.per_hour, settings.daily_cap @asynccontextmanager async def lifespan(app): yield await client.aclose() app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) @app.api_route("/{path:path}", methods=["GET", "HEAD", "POST"]) async def proxy(path, request: Request): public_path = "/" + path if settings.space_host and request.url.hostname != settings.space_host: return JSONResponse({"detail": "Not found."}, status_code=404) is_asset = public_path in ASSETS or bool(ASSET_ROUTES.fullmatch(public_path)) query_ok = not request.url.query if public_path == "/" and request.url.query: try: pairs = parse_qsl(request.url.query, keep_blank_values=True, strict_parsing=True) query_ok = request.url.query.partition("=")[0] == "__theme" and len(pairs) == 1 and pairs[0][0] == "__theme" and pairs[0][1] in {"system", "light", "dark"} except ValueError: query_ok = False if b"%" in request.scope.get("raw_path", b"").split(b"?", 1)[0] or not query_ok or (not is_asset and not ROUTES.fullmatch(public_path)): return JSONResponse({"detail": "Not found."}, status_code=404) is_post = request.method == "POST" if is_post and public_path != "/api/jobs": return JSONResponse({"detail": "Method not allowed."}, status_code=405) if not is_post and public_path == "/api/jobs": return JSONResponse({"detail": "Method not allowed."}, status_code=405) cookie = _upstream_cookie(request.headers.get("cookie", "")) # An explicit (possibly empty) Cookie header prevents the shared HTTP # client's upstream cookie jar from crossing browser ownership boundaries. outgoing = {"Accept": "*/*", "Accept-Encoding": "identity", "Cookie": cookie} body = b"" edge_cookie = None if is_post: if not settings.edge_secret: return JSONResponse({"detail": "Generation is temporarily unavailable."}, status_code=503) incoming = request.headers.get("origin") expected_host = settings.space_host or request.url.netloc if incoming: parsed = urlsplit(incoming) if parsed.scheme not in {"https", "http"} or parsed.netloc != expected_host or parsed.path or parsed.query or parsed.fragment or (settings.space_host and parsed.scheme != "https"): return JSONResponse({"detail": "Cross-origin requests are not allowed."}, status_code=403) if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": return JSONResponse({"detail": "A JSON request is required."}, status_code=415) chunks, size = [], 0 async for chunk in request.stream(): size += len(chunk) if size > 16_384: return JSONResponse({"detail": "Request is too large."}, status_code=413) chunks.append(chunk) body = b"".join(chunks) identity, edge_cookie = _edge_cookie(request, settings.edge_secret) pseudonym = _client(request, settings) if not await limits.admit(identity, pseudonym): return JSONResponse({"detail": "The arcade build limit has been reached. Please try again later."}, status_code=429) timestamp = str(int(time.time())) outgoing.update({"Content-Type": "application/json", "Origin": "https://alphabell.com", "X-Arcade-Edge-Client": pseudonym, "X-Arcade-Edge-Timestamp": timestamp, "X-Arcade-Edge-Signature": _signature(settings.edge_secret, timestamp + "\n" + pseudonym)}) target = settings.origin.rstrip("/") + public_path timeout = httpx.Timeout(20 if is_post else 35, connect=10) try: async with asyncio.timeout(20 if is_post else 35), client.stream(request.method, target, headers=outgoing, content=body, timeout=timeout) as upstream: if upstream.status_code >= 500: return JSONResponse({"detail": "The arcade is temporarily unavailable. Please try again shortly."}, status_code=502) cap = MAX_ASSET if is_asset else MAX_DOCUMENT payload = bytearray() async for chunk in upstream.aiter_bytes(): if len(payload) + len(chunk) > cap: return JSONResponse({"detail": "The arcade response is unavailable."}, status_code=502) payload.extend(chunk) headers = {} for name in ("content-type", "content-disposition", "content-security-policy", "permissions-policy", "referrer-policy", "x-content-type-options"): if name in upstream.headers: headers[name] = upstream.headers[name] headers["Cache-Control"] = "no-store" if public_path.startswith("/api/jobs") or is_post else "public, max-age=60" if "location" in upstream.headers: location = _rewrite_location(upstream.headers["location"], settings.origin) if location is None: return JSONResponse({"detail": "The arcade response is unavailable."}, status_code=502) headers["Location"] = location if public_path == "/" or public_path.startswith("/g/"): payload = bytes(payload).replace(b'', b'') response = Response(bytes(payload), status_code=upstream.status_code, headers=headers) for raw in upstream.headers.get_list("set-cookie"): rewritten = _rewrite_cookie(raw) if rewritten: response.headers.append("set-cookie", rewritten) if edge_cookie: response.headers.append("set-cookie", f"{EDGE_COOKIE}={edge_cookie}; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=None; Partitioned") return response except (httpx.HTTPError, ValueError, TimeoutError): return JSONResponse({"detail": "The arcade is temporarily unavailable. Please try again shortly."}, status_code=502) return app