Spaces:
Paused
Paused
File size: 9,685 Bytes
7bd88f8 78dd8a1 5518548 7bd88f8 | 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 | """Pi Web direct previews: a thin streaming relay on its own site.
Pi Web (https://mike0021-pi-web.hf.space) frames this Space to show a user's own dev server
(a Vite app on their machine's port 5173, say) natively. This origin is shared by every user
and holds nothing of value: each browser only ever holds its own user's capability, in an
HttpOnly cookie. The gateway checks the capability and resolves it to that user's machine.
GET /__pv/<cap>/<rest> handshake: cookie __pv=<cap>, 302 to /<rest> (the cap leaves the URL)
anything else needs the cookie; forwarded to GATEWAY/_hf/preview-relay/<path>?<query>
with x-hfpi-preview-relay (shared secret) and x-preview-cap; the app's own
cookies travel in x-preview-cookie. Bodies stream both ways (SSE too).
WebSocket upgrades are not supported.
"""
import base64
import http.cookiejar
import json
import os
import re
import time
import urllib.parse
import httpx
from starlette.applications import Starlette
from starlette.background import BackgroundTask
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse
from starlette.routing import Route
SECRET = os.environ.get("PREVIEW_RELAY_SECRET", "").strip()
GATEWAY = os.environ.get("PREVIEW_GATEWAY", "https://mike0021-pi-web.hf.space").strip().rstrip("/")
# Who may frame previews: Pi Web, and huggingface.co, which frames Pi Web on its Space page
# (frame-ancestors checks every ancestor, not only the parent).
FRAME_ANCESTORS = os.environ.get("PREVIEW_FRAME_ANCESTORS", f"{GATEWAY} https://huggingface.co").split()
COOKIE = "__pv"
RELAY_HEADER = "x-hfpi-preview-relay"
CAP_HEADER = "x-preview-cap"
COOKIE_HEADER = "x-preview-cookie"
MAX_TTL = 12 * 3600
CAP_RE = re.compile(r"[A-Za-z0-9_-]{8,400}\.[A-Za-z0-9_-]{43}")
HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "trailers", "transfer-encoding", "upgrade"}
DROP_REQUEST = HOP | {"host", "content-length", "cookie", "authorization", RELAY_HEADER, CAP_HEADER, COOKIE_HEADER,
"forwarded", "x-real-ip", "x-ip-token", "x-request-id", "x-amzn-trace-id"}
DROP_RESPONSE = HOP | {"content-length", "x-frame-options", "referrer-policy", "set-cookie", "content-security-policy"}
# Shared by every user: it must never keep cookies (httpx's jar would replay one user's app
# cookies on everyone's later requests).
_NO_COOKIES = http.cookiejar.CookieJar(policy=http.cookiejar.DefaultCookiePolicy(allowed_domains=[]))
_client = httpx.AsyncClient(
cookies=_NO_COOKIES,
timeout=httpx.Timeout(connect=15, read=None, write=120, pool=60),
limits=httpx.Limits(max_connections=1000, max_keepalive_connections=100),
)
def _page(status: int, title: str, text: str) -> HTMLResponse:
html = (f'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>{title}</title>'
'<style>body{font:14px/1.5 system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;'
'background:#111;color:#ddd}@media (prefers-color-scheme:light){body{background:#fff;color:#222}}'
f'main{{max-width:420px;padding:24px}}h1{{font-size:16px;margin:0 0 8px}}</style><main><h1>{title}</h1><p>{text}</p></main>')
return _harden(HTMLResponse(html, status_code=status, headers={"cache-control": "no-store"}))
def _harden(response: Response, policies: list[str] = ()) -> Response:
# The app's own policies without frame-ancestors, plus ours (every CSP header is enforced).
for policy in policies:
kept = [d.strip() for d in policy.split(";") if d.strip() and not d.strip().lower().startswith("frame-ancestors")]
if kept:
response.headers.append("content-security-policy", "; ".join(kept))
response.headers.append("content-security-policy", "frame-ancestors " + " ".join(FRAME_ANCESTORS))
# The app's own requests keep their Origin (no-referrer made a form POST's Origin "null", which
# CSRF checks refuse); nothing of the preview's URLs reaches other sites.
response.headers["referrer-policy"] = "same-origin"
# Every answer of this relay says so: a 5xx without it is Hugging Face's own, for a preview Space
# that is not serving (the gateway's link check tells the user that, not the app's error).
response.headers["x-pi-preview"] = "relay"
return response
def _ttl(cap: str) -> int:
"""Cookie lifetime from the cap's (unverified) expiry: the gateway verifies it on every request."""
try:
body = cap.split(".")[0]
expires = int(json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))["e"])
except (ValueError, KeyError, TypeError):
return 0
return max(0, min(MAX_TTL, expires - int(time.time())))
def rewrite_set_cookie(value: str) -> str | None:
"""An app cookie as a third-party iframe can keep it: Secure, SameSite=None, Partitioned, no Domain."""
parts = [p.strip() for p in value.split(";")]
name = parts[0].split("=", 1)[0].strip()
if not name or name == COOKIE:
return None # the app never sets (or clears) the preview's own cookie
attrs = [p for p in parts[1:] if p and p.split("=", 1)[0].strip().lower() not in ("domain", "secure", "samesite", "partitioned")]
return "; ".join([parts[0], *attrs, "Secure", "SameSite=None", "Partitioned"])
async def handshake(request: Request) -> Response:
cap = request.path_params["cap"]
if not CAP_RE.fullmatch(cap) or not (ttl := _ttl(cap)):
return _page(400, "Preview link invalid", "This preview link is malformed or expired. Reopen the preview from Pi Web.")
raw = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1").split("?", 1)[0]
rest = raw.split("/", 3)[3] if raw.count("/") >= 3 else "" # "/__pv/<cap>/<rest>", as encoded
target = "/" + rest.lstrip("/\\") # root-relative, never "//host"
if not _safe(target.lstrip("/")):
target = "/"
if request.url.query:
target += "?" + request.url.query
response = RedirectResponse(target, status_code=302, headers={"cache-control": "no-store"})
response.headers.append("set-cookie", f"{COOKIE}={cap}; Path=/; Max-Age={ttl}; Secure; HttpOnly; SameSite=None; Partitioned")
return _harden(response)
def _safe(path: str) -> bool:
for form in (path, urllib.parse.unquote(path), urllib.parse.unquote(urllib.parse.unquote(path))):
if any(seg in (".", "..") for seg in form.replace("\\", "/").split("/")):
return False
return bool(re.fullmatch(r"[\x21-\x7e]*", path))
async def relay(request: Request) -> Response:
cap = request.cookies.get(COOKIE)
if not cap or not CAP_RE.fullmatch(cap):
return _page(401, "Preview closed", "Reopen the preview from Pi Web.")
if not SECRET:
return _page(503, "Preview not configured", "This preview relay has no secret yet.")
raw = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1").split("?", 1)[0]
path = raw.lstrip("/")
if not _safe(path):
return _page(400, "Bad path", "Dot segments are not allowed in preview paths.")
url = f"{GATEWAY}/_hf/preview-relay/{path}"
if request.url.query:
url += "?" + request.url.query
headers = [(k, v) for k, v in request.headers.items()
if k.lower() not in DROP_REQUEST and not k.lower().startswith("x-forwarded-")]
if "accept-encoding" not in request.headers:
headers.append(("accept-encoding", "identity"))
app_cookies = [c.strip() for c in request.headers.get("cookie", "").split(";")
if c.strip() and c.strip().split("=", 1)[0].strip() != COOKIE]
if app_cookies:
headers.append((COOKIE_HEADER, "; ".join(app_cookies)))
headers += [(RELAY_HEADER, SECRET), (CAP_HEADER, cap)]
has_body = request.method not in ("GET", "HEAD", "OPTIONS") or "content-length" in request.headers or "transfer-encoding" in request.headers
try:
upstream = await _client.send(
_client.build_request(request.method, url, headers=headers, content=request.stream() if has_body else None), stream=True)
except httpx.HTTPError as e:
return _page(502, "Pi Web unreachable", f"The preview relay could not reach Pi Web ({e.__class__.__name__}).")
response = StreamingResponse(upstream.aiter_raw(), status_code=upstream.status_code, background=BackgroundTask(upstream.aclose))
for k, v in upstream.headers.multi_items():
k_low = k.lower()
if k_low == "set-cookie":
if (cookie := rewrite_set_cookie(v)) is not None:
response.headers.append("set-cookie", cookie)
elif k_low not in DROP_RESPONSE:
response.headers.append(k, v)
if upstream.headers.get("content-type", "").startswith("text/event-stream"):
response.headers["x-accel-buffering"] = "no"
# One origin for every preview: cached responses must not cross caps (another port, another user).
response.headers.append("vary", "Cookie")
return _harden(response, upstream.headers.get_list("content-security-policy"))
async def healthz(request: Request) -> Response:
return Response('{"ok":true}', media_type="application/json")
METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
app = Starlette(routes=[
Route("/__pv/healthz", healthz),
Route("/__pv/{cap}", handshake, methods=["GET", "HEAD"]),
Route("/__pv/{cap}/{rest:path}", handshake, methods=["GET", "HEAD"]),
Route("/{path:path}", relay, methods=METHODS),
])
|