Spaces:
Running
Running
File size: 11,876 Bytes
d5d5f09 7a50adf d5d5f09 7a50adf d5d5f09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | """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'<base href="/arcade/">', b'<base href="/">')
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
|