191 lines
7.3 KiB
Python
191 lines
7.3 KiB
Python
"""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'<base href="{prefix}/">'
|
|
if re.search(r"(?i)<head(?:\s[^>]*)?>", rewritten):
|
|
return re.sub(
|
|
r"(?i)<head(?:\s[^>]*)?>",
|
|
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)
|