"""Authenticated preview controls and signed static project file gateway.""" from __future__ import annotations import mimetypes import re import time from pathlib import Path, PurePosixPath from uuid import UUID import jwt from fastapi import Depends, HTTPException, Request from fastapi.responses import FileResponse, Response from core.paths import from_db_path from core.web_previews import ( WebPreviewError, get_web_preview, preview_dict, stop_web_preview, ) _HTML_MAX_REWRITE = 4 * 1024 * 1024 _CSS_MAX_REWRITE = 4 * 1024 * 1024 _ATTR_ROOT_URL = re.compile(r"(?i)(\b(?:src|href|poster|action)\s*=\s*['\"])/(?!/)") _CSS_ROOT_URL = re.compile(r"(?i)(url\(\s*['\"]?)/(?!/)") _CSS_ROOT_IMPORT = re.compile(r"(?i)(@import\s+['\"])/(?!/)") def _preview_headers(*, html: bool = False) -> dict[str, str]: headers = { "Cache-Control": "no-cache", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "Access-Control-Allow-Origin": "*", } if html: # The gateway currently shares the zcbot host. Header-level sandboxing also applies # when somebody opens the signed URL as a top-level page, so project code never gains # the host page's origin or localStorage even outside the SPA iframe. headers["Content-Security-Policy"] = ( "sandbox allow-downloads allow-forms allow-modals allow-scripts; " "frame-ancestors 'self'" ) return headers def _rewrite_html(text: str, prefix: str) -> str: rewritten = _ATTR_ROOT_URL.sub(lambda m: m.group(1) + prefix + "/", text) base = f'' if re.search(r"(?i)]*)?>", rewritten): return re.sub( r"(?i)]*)?>", lambda m: m.group(0) + base, rewritten, count=1, ) return base + rewritten def _rewrite_css(text: str, prefix: str) -> str: text = _CSS_ROOT_URL.sub(lambda m: m.group(1) + prefix + "/", text) return _CSS_ROOT_IMPORT.sub(lambda m: m.group(1) + prefix + "/", text) def _mint_preview_token(auth_cfg, preview_id: UUID, expires_at) -> str: now = int(time.time()) exp = int(expires_at.timestamp()) return jwt.encode( {"typ": "web_preview", "pid": str(preview_id), "iat": now, "exp": exp}, auth_cfg.jwt_secret, algorithm="HS256", ) def _verify_preview_token(auth_cfg, preview_id: UUID, token: str) -> None: try: payload = jwt.decode(token, auth_cfg.jwt_secret, algorithms=["HS256"]) except jwt.InvalidTokenError as exc: raise HTTPException(404, "preview not found") from exc if payload.get("typ") != "web_preview" or payload.get("pid") != str(preview_id): raise HTTPException(404, "preview not found") def _preview_asset(root: Path, raw_path: str) -> Path: raw = str(raw_path or "").replace("\\", "/").lstrip("/") path = PurePosixPath(raw) if any(part in {"", ".", ".."} for part in path.parts): raise HTTPException(404, "preview file not found") target = root.joinpath(*path.parts).resolve() try: target.relative_to(root.resolve()) except ValueError as exc: raise HTTPException(404, "preview file not found") from exc return target def register_web_preview_routes(app, *, require_user, auth_cfg) -> None: @app.get("/v1/web-previews/{preview_id}", tags=["web-previews"]) def web_preview_status( preview_id: UUID, user_id: UUID = Depends(require_user), # noqa: B008 ): try: row, _ = get_web_preview(preview_id, user_id) except WebPreviewError as exc: raise HTTPException(404, str(exc)) from exc return preview_dict(row) @app.post("/v1/web-previews/{preview_id}/launch", tags=["web-previews"]) def launch_web_preview( preview_id: UUID, user_id: UUID = Depends(require_user), # noqa: B008 ): try: row, _ = get_web_preview(preview_id, user_id) except WebPreviewError as exc: raise HTTPException(404, str(exc)) from exc if row.status != "active": raise HTTPException(410, f"preview is {row.status}") token = _mint_preview_token(auth_cfg, preview_id, row.expires_at) return { "preview": preview_dict(row), "url": f"/web-preview/{preview_id}/{token}/", } @app.delete("/v1/web-previews/{preview_id}", tags=["web-previews"]) def stop_preview( preview_id: UUID, user_id: UUID = Depends(require_user), # noqa: B008 ): try: return stop_web_preview(preview_id, user_id) except WebPreviewError as exc: raise HTTPException(404, str(exc)) from exc def serve(preview_id: UUID, token: str, asset_path: str, request: Request): _verify_preview_token(auth_cfg, preview_id, token) try: row, db_working_dir = get_web_preview(preview_id) except WebPreviewError as exc: raise HTTPException(404, "preview not found") from exc if row.status != "active": raise HTTPException(410, f"preview is {row.status}") working_dir = from_db_path(db_working_dir).resolve() root = _preview_asset(working_dir, row.root_path) if row.root_path != "." else working_dir if not root.is_dir(): raise HTTPException(404, "preview directory not found") requested = asset_path or row.entry_path target = _preview_asset(root, requested) if not target.is_file(): accepts_html = "text/html" in (request.headers.get("accept") or "") extensionless = not PurePosixPath(requested).suffix if row.spa_fallback and accepts_html and extensionless: target = _preview_asset(root, row.entry_path) if not target.is_file(): raise HTTPException(404, "preview file not found") prefix = f"/web-preview/{preview_id}/{token}" suffix = target.suffix.lower() size = target.stat().st_size if suffix in {".html", ".htm"} and size <= _HTML_MAX_REWRITE: text = target.read_text(encoding="utf-8", errors="replace") return Response( _rewrite_html(text, prefix), media_type="text/html; charset=utf-8", headers=_preview_headers(html=True), ) if suffix == ".css" and size <= _CSS_MAX_REWRITE: text = target.read_text(encoding="utf-8", errors="replace") return Response( _rewrite_css(text, prefix), media_type="text/css; charset=utf-8", headers=_preview_headers(), ) media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" return FileResponse( str(target), media_type=media_type, headers=_preview_headers(), ) @app.get("/web-preview/{preview_id}/{token}/", include_in_schema=False) def web_preview_entry(preview_id: UUID, token: str, request: Request): return serve(preview_id, token, "", request) @app.get("/web-preview/{preview_id}/{token}/{asset_path:path}", include_in_schema=False) def web_preview_asset(preview_id: UUID, token: str, asset_path: str, request: Request): return serve(preview_id, token, asset_path, request)