# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HTML preview route for assistant ```html fences.
Reason this route exists: when the chat renderer embeds the assistant's
HTML via a ``srcdoc`` iframe, Chromium inherits the embedder CSP
(``script-src 'self'``), so inline scripts and ``onclick`` handlers are
silently blocked. Serving the same HTML from a same-origin URL with an
overriding response-header CSP is the only way to let assistant-generated
interactive HTML actually run while keeping the surrounding Studio CSP
strict.
Security shape:
* POST is auth-gated (``get_current_subject``). Only an authenticated
caller can stash HTML into the in-memory store.
* GET is intentionally NOT auth-gated -- browsers do not attach the
Authorization bearer to iframe subresource loads, so we instead make
the URL itself the secret: ``secrets.token_urlsafe(24)`` (192 bits of
entropy). The token leaves the server only in the POST response and
is then placed into the iframe ``src`` by the requesting page. It is
never persisted to disk, never logged, and is wiped on TTL expiry.
* The response CSP is ``default-src 'none'`` + ``script-src
'unsafe-inline'`` so the preview is sandboxed from the network but
inline scripts and event-handler attributes execute as intended.
* The iframe still has ``sandbox="allow-scripts allow-modals
allow-popups"`` (no ``allow-same-origin``), so even though the URL
is same-origin the document is treated as a unique opaque origin
for SOP purposes -- script in the preview cannot reach
``window.parent`` storage, cookies, or DOM.
* A size cap and TTL cap bound the in-memory footprint per Studio
process.
"""
from __future__ import annotations
import secrets
import sys
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
# Backend root on sys.path so ``auth`` imports resolve when this module
# is loaded standalone (matches the pattern used by routes/export.py).
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from auth.authentication import get_current_subject # noqa: E402
router = APIRouter()
# ---------------------------------------------------------------------------
# Knobs (module-level so tests can monkeypatch).
# ---------------------------------------------------------------------------
# 1 MiB cap; assistant HTML previews are short snippets, not full SPAs.
MAX_HTML_PREVIEW_BYTES = 1_000_000
# 10 minutes. Long enough for a user to interact with the preview, short
# enough that a forgotten tab does not pin the entry.
PREVIEW_TTL_SECONDS = 10 * 60
# Defensive cap so a runaway producer cannot exhaust the worker. New POSTs
# evict the oldest entries past this watermark. Per-process, in-memory only.
MAX_LIVE_PREVIEWS = 256
_PREVIEWS: dict[str, tuple[float, str]] = {}
# ---------------------------------------------------------------------------
# Internal helpers.
# ---------------------------------------------------------------------------
def _sweep_expired(now: float | None = None) -> None:
now = time.monotonic() if now is None else now
expired = [k for k, (t, _) in _PREVIEWS.items() if now - t > PREVIEW_TTL_SECONDS]
for k in expired:
_PREVIEWS.pop(k, None)
def _evict_overflow() -> None:
if len(_PREVIEWS) <= MAX_LIVE_PREVIEWS:
return
# Evict oldest first.
sorted_keys = sorted(_PREVIEWS, key = lambda k: _PREVIEWS[k][0])
for k in sorted_keys[: len(_PREVIEWS) - MAX_LIVE_PREVIEWS]:
_PREVIEWS.pop(k, None)
def _build_html_doc(source: str) -> str:
# ```` mirrors the srcdoc fallback so any ````
# without an explicit target opens in a new tab rather than navigating
# the iframe (which would be UX-confusing).
return "" '' + source
_PREVIEW_CSP = "; ".join(
(
"default-src 'none'",
# ``script-src 'unsafe-inline'`` enables BOTH ``