Compare commits
21 commits
main
...
feat/html-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e45910e4 | ||
|
|
efda0b0c4c | ||
|
|
169e5b3e96 | ||
|
|
b62e4d18cd | ||
|
|
fff46cd462 | ||
|
|
60b002f1ea | ||
|
|
5171bcc991 | ||
|
|
2cc2e5cfe6 | ||
|
|
6e4d71d09f | ||
|
|
4f79a76397 | ||
|
|
17afdfeb8d | ||
|
|
e3c7948874 | ||
|
|
115ff8978c | ||
|
|
fc28f8486b | ||
|
|
b21717120c | ||
|
|
c3c09dbd80 | ||
|
|
42ff23de73 | ||
|
|
55e4d90d9e | ||
|
|
fb6a6703cb | ||
|
|
1385b8076e | ||
|
|
83e67231cd |
17 changed files with 4419 additions and 526 deletions
12
.github/workflows/security-audit.yml
vendored
12
.github/workflows/security-audit.yml
vendored
|
|
@ -1096,20 +1096,28 @@ jobs:
|
|||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Extract base-ref lockfile (PR triggers only)
|
||||
- name: Extract base-ref lockfile and install-script allowlist (PR triggers only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -e
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
git show "$BASE_SHA:studio/frontend/package-lock.json" \
|
||||
> /tmp/base-package-lock.json
|
||||
# Pull TRUSTED allowlist from base so a PR cannot self-approve
|
||||
# a new postinstall in the same diff. Missing-on-base = bootstrap
|
||||
# mode (gate accepts head allowlist for THAT PR only).
|
||||
if ! git show "$BASE_SHA:studio/frontend/.install-script-allowlist" \
|
||||
> /tmp/base-install-script-allowlist 2>/dev/null; then
|
||||
rm -f /tmp/base-install-script-allowlist
|
||||
fi
|
||||
|
||||
- name: Diff for newly-added install-script deps
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
python3 scripts/check_new_install_scripts.py \
|
||||
--base /tmp/base-package-lock.json \
|
||||
--head studio/frontend/package-lock.json
|
||||
--head studio/frontend/package-lock.json \
|
||||
--base-allowlist /tmp/base-install-script-allowlist
|
||||
|
||||
- name: Skip install-script diff (non-PR trigger)
|
||||
if: github.event_name != 'pull_request'
|
||||
|
|
|
|||
5
.github/workflows/studio-frontend-ci.yml
vendored
5
.github/workflows/studio-frontend-ci.yml
vendored
|
|
@ -109,6 +109,11 @@ jobs:
|
|||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Frontend unit tests (vitest)
|
||||
# Covers HtmlSvgRenderer sandbox / CSP / sanitizer. Runs before
|
||||
# build so a regression fails the gate even if the bundle is clean.
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
|
|
|
|||
|
|
@ -4,32 +4,21 @@
|
|||
|
||||
"""Diff two `package-lock.json` files and flag NEW install-script deps.
|
||||
|
||||
A package with `"hasInstallScript": true` runs `preinstall` / `install` /
|
||||
`postinstall` lifecycle hooks every time `npm ci` lays it down. Every
|
||||
npm supply-chain compromise of the last 18 months (Shai-Hulud,
|
||||
TanStack, axios-style, ArmorCode hijacks) leveraged exactly this lever:
|
||||
the attacker publishes a new malicious version of a dep we already
|
||||
trust, and the post-install hook runs the next time CI installs.
|
||||
Packages with `hasInstallScript: true` run preinstall/install/postinstall
|
||||
on every `npm ci`. Every npm supply-chain compromise of the last 18 months
|
||||
(Shai-Hulud, TanStack, axios, ArmorCode) used this lever: malicious
|
||||
version of a trusted dep, hook runs on next install.
|
||||
|
||||
This scanner refuses to allow a newly-introduced install-script dep to
|
||||
land without a maintainer eyeball on the lifecycle script body.
|
||||
Existing install-script deps are NOT re-flagged -- if `node-gyp` has
|
||||
been in the lockfile since day one, it's not part of this PR's threat
|
||||
model. Only new entries are surfaced.
|
||||
Existing install-script deps are NOT re-flagged; only NEW entries surface.
|
||||
|
||||
Supports lockfileVersion 1 (`dependencies` key, recursive), 2 and 3
|
||||
(flat `packages` key with `node_modules/<a>/node_modules/<b>` nesting
|
||||
for transitive entries). For each NEW install-script package we
|
||||
attempt a stdlib-only fetch of
|
||||
`https://registry.npmjs.org/<name>/<version>` to recover the actual
|
||||
postinstall command body. If the network is blocked we still emit the
|
||||
finding -- the lifecycle command body is informational, not
|
||||
load-bearing.
|
||||
Supports lockfileVersion 1 (`dependencies`, recursive) and 2/3 (flat
|
||||
`packages` with `node_modules/<a>/node_modules/<b>` nesting). For each
|
||||
finding we try a stdlib fetch of `https://registry.npmjs.org/<name>/<version>`
|
||||
to recover the lifecycle body; network failure is non-fatal.
|
||||
|
||||
Exit codes
|
||||
==========
|
||||
Exit codes:
|
||||
0 no newly-added install-script deps
|
||||
1 one or more newly-added install-script deps; listed on stderr
|
||||
1 newly-added install-script deps listed on stderr
|
||||
2 internal error (missing lockfile, malformed JSON, etc.)
|
||||
"""
|
||||
|
||||
|
|
@ -76,15 +65,11 @@ class Finding:
|
|||
|
||||
|
||||
def _strip_nm_prefix(key: str) -> str:
|
||||
"""Convert a v2/v3 `packages` key into a bare package name.
|
||||
|
||||
`node_modules/foo` -> `foo`; `node_modules/foo/node_modules/bar` ->
|
||||
`bar`. The empty key (`""`) is the project root and returns "".
|
||||
"""
|
||||
"""v2/v3 `packages` key -> bare leaf name. ``""`` is the project root."""
|
||||
if not key:
|
||||
return ""
|
||||
# Use the LAST `node_modules/` segment so transitives map to their
|
||||
# leaf name, matching how npm install resolves a postinstall.
|
||||
# Last `node_modules/` segment: transitives map to leaf name (matches
|
||||
# how npm resolves the postinstall).
|
||||
marker = "node_modules/"
|
||||
idx = key.rfind(marker)
|
||||
if idx == -1:
|
||||
|
|
@ -97,10 +82,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
|
|||
every entry with `hasInstallScript: true` (v2/v3) OR a
|
||||
non-empty `scripts.preinstall|install|postinstall` (v1).
|
||||
|
||||
The same package may appear at multiple versions in a single
|
||||
lockfile (de-duplicated copies under different parents); we key by
|
||||
`name@version` so we don't lose either copy. Returns a dict keyed
|
||||
by `name@version` -> the same string for convenience.
|
||||
Keyed by `name@version` so duplicate copies at different versions
|
||||
are not collapsed. Returns {`name@version`: name}.
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
version = lock.get("lockfileVersion")
|
||||
|
|
@ -120,10 +103,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
|
|||
ver = entry.get("version") or "<unversioned>"
|
||||
seen[f"{name}@{ver}"] = name
|
||||
|
||||
# v1 also embeds a `dependencies` tree; v2/v3 carry both for
|
||||
# backwards-compat but `packages` is canonical for them. For v1
|
||||
# there is no `hasInstallScript` flag, so look for a non-empty
|
||||
# `scripts.preinstall|install|postinstall` directly.
|
||||
# v1 has no `hasInstallScript` flag -- detect via non-empty
|
||||
# scripts.{preinstall,install,postinstall}.
|
||||
def _walk_v1(deps: dict, depth: int = 0) -> None:
|
||||
if depth > 64 or not isinstance(deps, dict):
|
||||
return
|
||||
|
|
@ -135,8 +116,6 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
|
|||
isinstance(scripts, dict) and scripts.get(hook)
|
||||
for hook in ("preinstall", "install", "postinstall")
|
||||
)
|
||||
# v1 also sets `requires` only on the parent, no flag, so
|
||||
# the lifecycle-script presence is the only signal.
|
||||
if lifecycle:
|
||||
ver = entry.get("version") or "<unversioned>"
|
||||
seen[f"{name}@{ver}"] = name
|
||||
|
|
@ -163,12 +142,9 @@ def _load_lockfile(path: Path) -> dict:
|
|||
|
||||
|
||||
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
|
||||
"""Return {hook: command} for any of preinstall / install /
|
||||
postinstall published in the registry metadata for this name@ver.
|
||||
"""Return {hook: command} from registry metadata, or None on any failure.
|
||||
|
||||
Returns None on any error (network blocked, 404, malformed JSON).
|
||||
Never raises; the caller treats absence as "could not enrich, emit
|
||||
finding anyway".
|
||||
Never raises; absence means "emit finding without enrichment".
|
||||
"""
|
||||
safe_name = urllib.parse.quote(name, safe = "@/")
|
||||
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
|
||||
|
|
@ -203,9 +179,8 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
|
|||
findings: list[Finding] = []
|
||||
for key in sorted(head):
|
||||
if key in base:
|
||||
continue # pre-existing install-script dep; not in scope
|
||||
continue # pre-existing dep, out of scope
|
||||
name = head[key]
|
||||
# key is "name@version"; rsplit("@", 1) handles scoped names.
|
||||
version = (
|
||||
key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
|
||||
)
|
||||
|
|
@ -215,8 +190,7 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
|
|||
else:
|
||||
detail = (
|
||||
"newly added with hasInstallScript=true; registry "
|
||||
"metadata unreachable -- inspect the package's "
|
||||
"scripts.{preinstall,install,postinstall} manually"
|
||||
"unreachable -- inspect scripts.{preinstall,install,postinstall}"
|
||||
)
|
||||
findings.append(
|
||||
Finding(
|
||||
|
|
@ -235,6 +209,40 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_allowlist(path: Path) -> set[str]:
|
||||
"""Newline-separated `name@version` entries to skip.
|
||||
|
||||
Each entry MUST be pinned to an exact version; bare names rejected so
|
||||
a compromised maintainer cannot ship a malicious later version under
|
||||
an existing allowlist line. `#` comments and blank lines ignored.
|
||||
Missing file = empty set.
|
||||
"""
|
||||
if not path.exists():
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
try:
|
||||
text = path.read_text(encoding = "utf-8")
|
||||
except OSError:
|
||||
return set()
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
name, sep, version = line.rpartition("@")
|
||||
if not sep or not name or not version:
|
||||
raise ValueError(
|
||||
f"{path}: allowlist entry {line!r} must be pinned to an "
|
||||
"exact version (e.g. 'esbuild@0.21.5'). Bare names are "
|
||||
"rejected so we cannot silently approve a later release.",
|
||||
)
|
||||
out.add(line.lower())
|
||||
return out
|
||||
|
||||
|
||||
def _finding_allowlist_key(finding: Finding) -> str:
|
||||
return f"{finding.name}@{finding.version}".lower()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description = (
|
||||
|
|
@ -252,6 +260,23 @@ def main(argv: list[str] | None = None) -> int:
|
|||
required = True,
|
||||
help = "Path to the HEAD package-lock.json (this PR).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowlist",
|
||||
default = None,
|
||||
help = (
|
||||
"Path to the HEAD newline-separated 'name@version' allowlist "
|
||||
"to skip. Defaults to '<head dir>/.install-script-allowlist'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-allowlist",
|
||||
default = None,
|
||||
help = (
|
||||
"Path to the TRUSTED BASE allowlist. Defaults to "
|
||||
"'<base dir>/.install-script-allowlist'. Head-only entries that "
|
||||
"self-approve a new postinstall fail the gate."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
|
|
@ -261,7 +286,96 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
findings = diff_new_install_scripts(base_lock, head_lock)
|
||||
head_allowlist_path = (
|
||||
Path(args.allowlist)
|
||||
if args.allowlist
|
||||
else Path(args.head).parent / ".install-script-allowlist"
|
||||
)
|
||||
base_allowlist_path = (
|
||||
Path(args.base_allowlist)
|
||||
if args.base_allowlist
|
||||
else Path(args.base).parent / ".install-script-allowlist"
|
||||
)
|
||||
|
||||
try:
|
||||
head_allowlist = _load_allowlist(head_allowlist_path)
|
||||
except ValueError as exc:
|
||||
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
raw_findings = diff_new_install_scripts(base_lock, head_lock)
|
||||
raw_findings_keys = {_finding_allowlist_key(f) for f in raw_findings}
|
||||
|
||||
# Bootstrap: PR that creates the file. Workflow signals "missing on base"
|
||||
# by `rm -f` after a failed `git show`. After landing, head-only / delete
|
||||
# rules below kick in.
|
||||
if not base_allowlist_path.exists():
|
||||
print(
|
||||
f"[install-script-diff] bootstrap: {base_allowlist_path} "
|
||||
"missing on base; accepting head allowlist as-is for this run.",
|
||||
flush = True,
|
||||
)
|
||||
allowlist = head_allowlist
|
||||
else:
|
||||
try:
|
||||
base_allowlist = _load_allowlist(base_allowlist_path)
|
||||
except ValueError as exc:
|
||||
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
added_head_only = head_allowlist - base_allowlist
|
||||
# Refuse "introduce a new postinstall AND allowlist it in the same
|
||||
# diff". Head-only entries that match no current finding are fine
|
||||
# (prepare trust for a follow-up PR; reviewer still sees the diff).
|
||||
self_approving = sorted(added_head_only & raw_findings_keys)
|
||||
if self_approving:
|
||||
print(
|
||||
"[install-script-diff] FAIL: this PR both introduces an "
|
||||
"install-script dependency AND allowlists it in the "
|
||||
"same diff. Allowlist entries that approve a NEW "
|
||||
"postinstall must land on the base branch first.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
for entry in self_approving:
|
||||
print(
|
||||
f" self-approving allowlist entry: {entry}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Refuse PRs that DROP trusted base entries; otherwise a two-step
|
||||
# bypass works (PR A removes file -> PR B hits bootstrap and
|
||||
# self-allowlists). Deletions go via admin override.
|
||||
removed_from_head = sorted(base_allowlist - head_allowlist)
|
||||
if removed_from_head:
|
||||
print(
|
||||
"[install-script-diff] FAIL: PR removes trusted base "
|
||||
"allowlist entries. Allowlist deletions must land in a "
|
||||
"separate, isolated commit so a follow-up PR cannot "
|
||||
"exploit the bootstrap path.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
for entry in removed_from_head:
|
||||
print(
|
||||
f" dropped allowlist entry: {entry}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Only base allowlist applies. Head-only entries that survived
|
||||
# the self-approval check wait for the next PR to take effect.
|
||||
allowlist = base_allowlist
|
||||
|
||||
findings = raw_findings
|
||||
if allowlist:
|
||||
skipped = [f for f in findings if _finding_allowlist_key(f) in allowlist]
|
||||
findings = [f for f in findings if _finding_allowlist_key(f) not in allowlist]
|
||||
for f in skipped:
|
||||
print(
|
||||
f"[install-script-diff] SKIP {_finding_allowlist_key(f)} "
|
||||
"(allowlisted via trusted base allowlist)",
|
||||
flush = True,
|
||||
)
|
||||
if not findings:
|
||||
print(
|
||||
"[install-script-diff] OK: no newly-added install-script "
|
||||
|
|
@ -279,10 +393,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(str(f), file = sys.stderr)
|
||||
print(file = sys.stderr)
|
||||
print(
|
||||
"[install-script-diff] Refusing to proceed. Every new "
|
||||
"install-script dep is a postinstall lifecycle hook that "
|
||||
"would run on the next `npm ci`. Review each finding above, "
|
||||
"confirm the maintainer + version, and re-run.",
|
||||
"[install-script-diff] Refusing to proceed. Each new install-script "
|
||||
"dep ships a postinstall hook that runs on next `npm ci`. Review, "
|
||||
"confirm maintainer + version, and re-run.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ from routes import (
|
|||
data_recipe_router,
|
||||
datasets_router,
|
||||
export_router,
|
||||
html_preview_router,
|
||||
inference_router,
|
||||
inference_studio_router,
|
||||
models_router,
|
||||
|
|
@ -329,6 +330,10 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
|
|||
"style-src 'self' 'unsafe-inline'; "
|
||||
f"{script_src}; "
|
||||
"font-src 'self' data:; "
|
||||
# Same-origin only. SVG uses srcdoc; interactive HTML goes through
|
||||
# /api/preview/html/{id} which carries its own overriding response
|
||||
# CSP (srcdoc / data: / blob: inherit THIS one per CSP3).
|
||||
"frame-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"form-action 'self'; "
|
||||
"base-uri 'self'"
|
||||
|
|
@ -530,6 +535,9 @@ app.include_router(export_router, prefix = "/api/export", tags = ["export"])
|
|||
app.include_router(
|
||||
training_history_router, prefix = "/api/train", tags = ["training-history"]
|
||||
)
|
||||
app.include_router(
|
||||
html_preview_router, prefix = "/api/preview/html", tags = ["html-preview"]
|
||||
)
|
||||
|
||||
|
||||
# ============ Health and System Endpoints ============
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from routes.export import router as export_router
|
|||
from routes.training_history import router as training_history_router
|
||||
from routes.chat_history import router as chat_history_router
|
||||
from routes.providers import router as providers_router
|
||||
from routes.html_preview import router as html_preview_router
|
||||
|
||||
__all__ = [
|
||||
"training_router",
|
||||
|
|
@ -29,4 +30,5 @@ __all__ = [
|
|||
"training_history_router",
|
||||
"chat_history_router",
|
||||
"providers_router",
|
||||
"html_preview_router",
|
||||
]
|
||||
|
|
|
|||
143
studio/backend/routes/html_preview.py
Normal file
143
studio/backend/routes/html_preview.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# 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.
|
||||
|
||||
srcdoc / data: / blob: iframes inherit the host CSP (script-src 'self')
|
||||
per CSP3, so inline scripts and onclick handlers are silently blocked.
|
||||
Serving the snippet from a same-origin URL with an overriding response
|
||||
CSP is the only way to unlock interactive HTML.
|
||||
|
||||
Security:
|
||||
- POST is auth-gated.
|
||||
- GET is NOT auth-gated -- iframe subresource loads do not carry the
|
||||
Authorization bearer, so the URL token (192 bits, secrets.token_urlsafe(24))
|
||||
is the authorisation. Never persisted, wiped on TTL expiry.
|
||||
- Response CSP: default-src 'none' + script-src 'unsafe-inline'.
|
||||
Scripts run; network egress (connect-src, frame-src, worker-src) blocked.
|
||||
- iframe sandbox stays "allow-scripts allow-modals allow-popups" with NO
|
||||
allow-same-origin, so preview JS cannot reach window.parent.
|
||||
- Size and live-entry caps bound per-process memory.
|
||||
"""
|
||||
|
||||
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 on sys.path for standalone load (matches 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).
|
||||
MAX_HTML_PREVIEW_BYTES = 1_000_000 # 1 MiB; snippets, not full SPAs.
|
||||
PREVIEW_TTL_SECONDS = 10 * 60 # 10 min interaction window.
|
||||
MAX_LIVE_PREVIEWS = 256 # Per-process cap; oldest evicted first.
|
||||
|
||||
_PREVIEWS: dict[str, tuple[float, str]] = {}
|
||||
|
||||
|
||||
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
|
||||
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:
|
||||
# <base target="_blank"> so <a> links open a new tab instead of
|
||||
# navigating the iframe away from the preview.
|
||||
return "<!doctype html>" '<base target="_blank">' + source
|
||||
|
||||
|
||||
_PREVIEW_CSP = "; ".join(
|
||||
(
|
||||
"default-src 'none'",
|
||||
# The reason this route exists -- host CSP does not allow inline.
|
||||
"script-src 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
# data: / blob: only -- no remote img beacon.
|
||||
"img-src data: blob:",
|
||||
"media-src data: blob:",
|
||||
"font-src data:",
|
||||
"connect-src 'none'",
|
||||
"worker-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
# Same-origin embedders only; also overrides X-Frame-Options.
|
||||
"frame-ancestors 'self'",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class HtmlPreviewCreate(BaseModel):
|
||||
source: str = Field(..., max_length = MAX_HTML_PREVIEW_BYTES)
|
||||
|
||||
|
||||
class HtmlPreviewCreateResponse(BaseModel):
|
||||
url: str
|
||||
expires_in_seconds: int
|
||||
|
||||
|
||||
@router.post("", response_model = HtmlPreviewCreateResponse)
|
||||
async def create_html_preview(
|
||||
payload: HtmlPreviewCreate,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> HtmlPreviewCreateResponse:
|
||||
"""Stash HTML; return a same-origin token URL (the only auth for GET)."""
|
||||
_sweep_expired()
|
||||
source = payload.source
|
||||
if not isinstance(source, str): # defensive; pydantic already enforces.
|
||||
raise HTTPException(status_code = 400, detail = "source must be a string")
|
||||
if len(source) > MAX_HTML_PREVIEW_BYTES:
|
||||
raise HTTPException(status_code = 413, detail = "HTML preview too large")
|
||||
token = secrets.token_urlsafe(24)
|
||||
_PREVIEWS[token] = (time.monotonic(), source)
|
||||
_evict_overflow()
|
||||
return HtmlPreviewCreateResponse(
|
||||
url = f"/api/preview/html/{token}",
|
||||
expires_in_seconds = PREVIEW_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{preview_id}", response_class = HTMLResponse)
|
||||
async def get_html_preview(preview_id: str) -> HTMLResponse:
|
||||
"""Serve a stashed snippet with overriding CSP. Unauth: token IS the auth."""
|
||||
_sweep_expired()
|
||||
item = _PREVIEWS.get(preview_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code = 404, detail = "Preview expired or not found")
|
||||
_, source = item
|
||||
return HTMLResponse(
|
||||
content = _build_html_doc(source),
|
||||
headers = {
|
||||
"Content-Security-Policy": _PREVIEW_CSP,
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
# Override the global DENY default so the host chat can iframe us.
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
},
|
||||
)
|
||||
|
|
@ -435,6 +435,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
|
|||
data_recipe_router = APIRouter(),
|
||||
datasets_router = APIRouter(),
|
||||
export_router = APIRouter(),
|
||||
html_preview_router = APIRouter(),
|
||||
inference_router = APIRouter(),
|
||||
inference_studio_router = APIRouter(),
|
||||
models_router = APIRouter(),
|
||||
|
|
|
|||
191
studio/backend/tests/test_html_preview.py
Normal file
191
studio/backend/tests/test_html_preview.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Tests for the /api/preview/html route (interactive HTML preview)."""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def preview_app(tmp_path, monkeypatch):
|
||||
"""Standalone app with only the html-preview router and a clean store."""
|
||||
from auth import storage
|
||||
from auth.authentication import create_access_token
|
||||
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
||||
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
||||
|
||||
import secrets as _secrets
|
||||
|
||||
storage.create_initial_user(
|
||||
username = storage.DEFAULT_ADMIN_USERNAME,
|
||||
password = "human-password-123",
|
||||
jwt_secret = _secrets.token_urlsafe(64),
|
||||
must_change_password = False,
|
||||
)
|
||||
|
||||
from routes.html_preview import router as html_preview_router
|
||||
from routes import html_preview as html_preview_module
|
||||
|
||||
html_preview_module._PREVIEWS.clear() # fresh store per test
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
html_preview_router, prefix = "/api/preview/html", tags = ["html-preview"]
|
||||
)
|
||||
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
|
||||
return app, token, html_preview_module
|
||||
|
||||
|
||||
class TestPostHtmlPreview:
|
||||
def test_post_requires_auth(self, preview_app):
|
||||
app, _token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
r = c.post("/api/preview/html", json = {"source": "<h1>hi</h1>"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
def test_post_returns_same_origin_url_and_ttl(self, preview_app):
|
||||
app, token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": "<h1>hello</h1>"},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["url"].startswith("/api/preview/html/")
|
||||
assert isinstance(body["expires_in_seconds"], int)
|
||||
assert body["expires_in_seconds"] > 0
|
||||
|
||||
def test_post_rejects_oversize_body(self, preview_app):
|
||||
app, token, mod = preview_app
|
||||
c = TestClient(app)
|
||||
too_big = "x" * (mod.MAX_HTML_PREVIEW_BYTES + 1)
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": too_big},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
# Pydantic's max_length surfaces as 422; we accept any non-2xx
|
||||
# as long as nothing is stored.
|
||||
assert r.status_code >= 400
|
||||
assert mod._PREVIEWS == {}
|
||||
|
||||
def test_post_returns_unguessable_tokens(self, preview_app):
|
||||
# Identical source -> distinct tokens.
|
||||
app, token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
urls = set()
|
||||
for _ in range(5):
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": "<p>same</p>"},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
urls.add(r.json()["url"])
|
||||
assert len(urls) == 5
|
||||
|
||||
|
||||
class TestGetHtmlPreview:
|
||||
def _create(self, app, token, source):
|
||||
c = TestClient(app)
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": source},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
return r.json()["url"]
|
||||
|
||||
def test_get_serves_stored_html_with_overriding_csp(self, preview_app):
|
||||
app, token, _mod = preview_app
|
||||
url = self._create(app, token, "<button onclick=\"alert('x')\">go</button>")
|
||||
c = TestClient(app)
|
||||
r = c.get(url)
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
assert "<!doctype html>" in body.lower()
|
||||
assert '<base target="_blank">' in body
|
||||
assert "<button onclick=\"alert('x')\">go</button>" in body
|
||||
csp = r.headers["content-security-policy"]
|
||||
directives = {
|
||||
chunk.strip().split(" ", 1)[0]: chunk.strip()
|
||||
for chunk in csp.split(";")
|
||||
if chunk.strip()
|
||||
}
|
||||
# Inline scripts allowed, network egress closed.
|
||||
assert "'none'" in directives["default-src"]
|
||||
assert "'unsafe-inline'" in directives["script-src"]
|
||||
assert "'none'" in directives["connect-src"]
|
||||
assert "'none'" in directives["frame-src"]
|
||||
# Same-origin embedders only.
|
||||
assert "'self'" in directives["frame-ancestors"]
|
||||
# Override the global DENY so host chat can iframe us.
|
||||
assert r.headers["x-frame-options"].upper() == "SAMEORIGIN"
|
||||
assert "no-store" in r.headers["cache-control"]
|
||||
|
||||
def test_get_is_not_auth_gated(self, preview_app):
|
||||
# Iframe subresource loads do not carry Authorization; the URL
|
||||
# token is the authorisation.
|
||||
app, token, _mod = preview_app
|
||||
url = self._create(app, token, "<p>nope</p>")
|
||||
c = TestClient(app)
|
||||
r = c.get(url) # no Authorization header
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_get_unknown_token_is_404(self, preview_app):
|
||||
app, _token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
r = c.get("/api/preview/html/totally-not-a-real-token")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_get_expired_token_is_404(self, preview_app):
|
||||
app, token, mod = preview_app
|
||||
url = self._create(app, token, "<p>aging</p>")
|
||||
# Rewind monotonic past the TTL.
|
||||
token_id = url.rsplit("/", 1)[-1]
|
||||
created, src = mod._PREVIEWS[token_id]
|
||||
mod._PREVIEWS[token_id] = (created - (mod.PREVIEW_TTL_SECONDS + 5), src)
|
||||
c = TestClient(app)
|
||||
r = c.get(url)
|
||||
assert r.status_code == 404
|
||||
# Swept on access.
|
||||
assert token_id not in mod._PREVIEWS
|
||||
|
||||
|
||||
class TestEviction:
|
||||
def test_overflow_evicts_oldest_entries(self, preview_app):
|
||||
app, token, mod = preview_app
|
||||
c = TestClient(app)
|
||||
mod.MAX_LIVE_PREVIEWS = 4 # cheap test
|
||||
urls = []
|
||||
for i in range(6):
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": f"<p>{i}</p>"},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
urls.append(r.json()["url"])
|
||||
time.sleep(0.001) # deterministic monotonic order
|
||||
assert len(mod._PREVIEWS) == mod.MAX_LIVE_PREVIEWS
|
||||
# Oldest two evicted.
|
||||
for old in urls[:2]:
|
||||
token_id = old.rsplit("/", 1)[-1]
|
||||
assert token_id not in mod._PREVIEWS
|
||||
# Newest survive.
|
||||
for fresh in urls[-mod.MAX_LIVE_PREVIEWS :]:
|
||||
token_id = fresh.rsplit("/", 1)[-1]
|
||||
assert token_id in mod._PREVIEWS
|
||||
|
|
@ -79,8 +79,7 @@ class TestMaxBodyMiddleware:
|
|||
assert r.json()["unprotected"] is True
|
||||
|
||||
def test_chunked_upload_over_cap_rejected(self, main_module):
|
||||
# Regression: declared-Content-Length-only check could be bypassed
|
||||
# by chunked transfer-encoding.
|
||||
# Regression: Content-Length-only check missed chunked uploads.
|
||||
app = _make_protected_app(1024, main_module)
|
||||
c = TestClient(app)
|
||||
|
||||
|
|
@ -156,7 +155,7 @@ class TestSecurityHeadersMiddleware:
|
|||
r = c.get("/plain")
|
||||
assert r.status_code == 200
|
||||
csp = r.headers["content-security-policy"]
|
||||
# Parse per-directive so style-src unsafe-inline does not false-match.
|
||||
# Parse per directive so style-src unsafe-inline does not false-match.
|
||||
directives = {
|
||||
chunk.strip().split(" ", 1)[0]: chunk.strip()
|
||||
for chunk in csp.split(";")
|
||||
|
|
@ -184,7 +183,7 @@ class TestSecurityHeadersMiddleware:
|
|||
r = c.get("/with-nonce")
|
||||
csp = r.headers["content-security-policy"]
|
||||
assert f"'nonce-{nonce}'" in csp
|
||||
# Internal handoff header must not leak to clients.
|
||||
# Internal handoff header must not leak.
|
||||
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {
|
||||
k.lower() for k in r.headers.keys()
|
||||
}
|
||||
|
|
@ -196,17 +195,34 @@ class TestSecurityHeadersMiddleware:
|
|||
nonced = main_module._build_csp("XYZ")
|
||||
assert "script-src 'self' 'nonce-XYZ';" in nonced
|
||||
|
||||
def test_frame_src_is_explicitly_self_only(self, main_module):
|
||||
# SVG preview uses srcdoc, HTML preview uses /api/preview/html
|
||||
# (same-origin). srcdoc / data: / blob: all inherit this CSP per
|
||||
# CSP3, so broadening here would not unlock scripts. Explicit
|
||||
# 'self' leaves a visible directive future changes must broaden.
|
||||
csp = main_module._build_csp()
|
||||
frame_src = next(
|
||||
chunk.strip()
|
||||
for chunk in csp.split(";")
|
||||
if chunk.strip().startswith("frame-src ")
|
||||
)
|
||||
tokens = frame_src.split()
|
||||
assert tokens[0] == "frame-src"
|
||||
assert "'self'" in tokens
|
||||
assert "data:" not in tokens
|
||||
assert "blob:" not in tokens
|
||||
|
||||
def test_img_src_allows_google_favicons(self, main_module):
|
||||
# sources.tsx fetches https://www.google.com/s2/favicons?... ; without
|
||||
# this allowlist entry citation favicons fall back to gray initials.
|
||||
# sources.tsx fetches https://www.google.com/s2/favicons?...;
|
||||
# without this, citation favicons fall back to grey initials.
|
||||
csp = main_module._build_csp()
|
||||
img_directive = next(
|
||||
chunk.strip()
|
||||
for chunk in csp.split(";")
|
||||
if chunk.strip().startswith("img-src ")
|
||||
)
|
||||
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
|
||||
# not read directive-string `in` membership as URL sanitisation.
|
||||
# Tokenise + `==`: CodeQL's URL-substring rule would otherwise
|
||||
# read `in` membership as URL sanitisation.
|
||||
img_sources = img_directive.split()
|
||||
assert any(src == "https://www.google.com" for src in img_sources)
|
||||
# Pre-existing favicon CDNs stay allowed.
|
||||
|
|
@ -250,9 +266,9 @@ def health_app(tmp_path, monkeypatch):
|
|||
|
||||
|
||||
class TestHealthAuthGate:
|
||||
# Launcher / frontend bootstrap fields are available unauth so the Tauri
|
||||
# watchdog can re-adopt a sibling backend and the SPA can detect chat-only
|
||||
# mode before any token exists. Version / device_type still require a bearer.
|
||||
# Bootstrap fields are unauth so the Tauri watchdog can re-adopt a
|
||||
# sibling backend and SPA can detect chat-only before any token exists.
|
||||
# Version / device_type still require a bearer.
|
||||
LAUNCHER_BITS = (
|
||||
"service",
|
||||
"studio_root_id",
|
||||
|
|
@ -279,7 +295,7 @@ class TestHealthAuthGate:
|
|||
assert forbidden not in body
|
||||
|
||||
def test_invalid_bearer_returns_launcher_bits_only(self, health_app):
|
||||
# Regression: calling the async dep without await made any Bearer header pass.
|
||||
# Regression: missing await on the async dep let any Bearer pass.
|
||||
c = TestClient(health_app)
|
||||
r = c.get(
|
||||
"/api/health",
|
||||
|
|
|
|||
11
studio/frontend/.install-script-allowlist
Normal file
11
studio/frontend/.install-script-allowlist
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Reviewed install-script (postinstall) packages, exempt from
|
||||
# scripts/check_new_install_scripts.py. Pin each as "name@version" -- a
|
||||
# maintainer compromise shipping a new malicious version must NOT slip
|
||||
# through the same allowlist line.
|
||||
#
|
||||
# Rules: read the install body before adding. Entries MUST land on main
|
||||
# first; the gate rejects head-only additions that self-approve a new dep.
|
||||
|
||||
# evanw/esbuild: postinstall downloads platform native binary. Pulled in
|
||||
# transitively by vitest for dev-only test transforms; no runtime exposure.
|
||||
esbuild@0.21.5
|
||||
2635
studio/frontend/package-lock.json
generated
2635
studio/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,9 @@
|
|||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"biome:check": "biome check",
|
||||
"biome:fix": "biome check --write"
|
||||
"biome:fix": "biome check --write",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.1.17",
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dexie": "^4.3.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"fflate": "0.8.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"katex": "^0.16.28",
|
||||
|
|
@ -91,13 +94,18 @@
|
|||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,397 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
HtmlSvgRenderer,
|
||||
isHtmlFence,
|
||||
isSvgFence,
|
||||
parseCodeFence,
|
||||
parseIncompleteCodeFence,
|
||||
sanitizeSvgSource,
|
||||
} from "../html-svg-renderer";
|
||||
|
||||
// Fake the /api/preview/html POST round-trip so the iframe reaches a
|
||||
// deterministic post-load state under jsdom.
|
||||
let _previewIdCounter = 0;
|
||||
function installFetchStub(): void {
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (typeof input === "string" && input === "/api/preview/html") {
|
||||
const url = `/api/preview/html/test-token-${++_previewIdCounter}`;
|
||||
return new Response(JSON.stringify({ url }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
function installFailingFetchStub(): void {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response("server boom", { status: 500 }),
|
||||
) as typeof fetch;
|
||||
}
|
||||
|
||||
describe("HtmlSvgRenderer", () => {
|
||||
beforeEach(() => {
|
||||
installFetchStub();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders an HTML preview inside a sandboxed iframe pointing at the same-origin preview route", async () => {
|
||||
const html = "<html><body><h1>hello</h1></body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
const root = screen.getByTestId("html-svg-renderer");
|
||||
expect(root.getAttribute("data-active-tab")).toBe("preview");
|
||||
expect(root.getAttribute("data-language")).toBe("html");
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
expect(iframe.tagName).toBe("IFRAME");
|
||||
// allow-scripts/allow-modals/allow-popups grant the runtime the
|
||||
// route CSP unlocks; NOT granting allow-same-origin / allow-top-nav /
|
||||
// allow-popups-to-escape-sandbox blocks parent access and tabnabbing.
|
||||
const sandbox = iframe.getAttribute("sandbox") ?? "";
|
||||
const sandboxTokens = sandbox.split(/\s+/);
|
||||
expect(sandboxTokens).toContain("allow-scripts");
|
||||
expect(sandboxTokens).toContain("allow-modals");
|
||||
expect(sandboxTokens).toContain("allow-popups");
|
||||
expect(sandboxTokens).not.toContain("allow-popups-to-escape-sandbox");
|
||||
expect(sandbox).not.toContain("allow-same-origin");
|
||||
expect(sandbox).not.toContain("allow-top-navigation");
|
||||
|
||||
// In-flight: about:blank (no flash of previous preview).
|
||||
expect(["about:blank", null]).toContain(iframe.getAttribute("src"));
|
||||
|
||||
// After POST resolves: iframe src is the returned same-origin URL.
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
const src = iframe.getAttribute("src") ?? "";
|
||||
expect(src.startsWith("/api/preview/html/")).toBe(true);
|
||||
expect(iframe.getAttribute("srcdoc")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to srcdoc with the defense-in-depth meta CSP when the preview API is unreachable", async () => {
|
||||
installFailingFetchStub();
|
||||
const html = "<html><body><h1>fallback</h1></body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("error");
|
||||
});
|
||||
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
|
||||
expect(srcdoc).toContain("<h1>fallback</h1>");
|
||||
expect(srcdoc).toContain('http-equiv="Content-Security-Policy"');
|
||||
expect(srcdoc).toContain("connect-src 'none'");
|
||||
expect(srcdoc).toContain("frame-src 'none'");
|
||||
});
|
||||
|
||||
it("renders an SVG preview inside a no-script sandboxed iframe with srcdoc carrying the sanitized markup", () => {
|
||||
const malicious = `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="blue" onclick="alert('pwn')" />
|
||||
<script>window.parent.alert("pwn")</script>
|
||||
</svg>`;
|
||||
|
||||
render(<HtmlSvgRenderer language="svg" source={malicious} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-svg-preview",
|
||||
) as HTMLIFrameElement;
|
||||
expect(iframe.tagName).toBe("IFRAME");
|
||||
// SVG iframe must NEVER allow scripts or same-origin (XSS / host leak).
|
||||
expect(iframe.getAttribute("sandbox")).toBe("");
|
||||
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
|
||||
expect(srcdoc).toContain("<circle");
|
||||
expect(srcdoc).not.toContain("<script");
|
||||
expect(srcdoc).not.toContain("onclick");
|
||||
expect(srcdoc).not.toContain("alert");
|
||||
// CSP backstop: data: images only so a sanitizer regression cannot beacon.
|
||||
expect(srcdoc).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("toggles between Preview and Code tabs", async () => {
|
||||
const html = "<html><body>hi</body></html>";
|
||||
render(
|
||||
<HtmlSvgRenderer
|
||||
language="html"
|
||||
source={html}
|
||||
codeView={<pre data-testid="custom-code-view">{html}</pre>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Default tab is preview.
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(screen.queryByTestId("custom-code-view")).toBeNull();
|
||||
// Settle the preview POST so it does not raise an out-of-act() warning.
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
|
||||
const codeTab = screen.getByRole("tab", { name: /code/i });
|
||||
act(() => {
|
||||
fireEvent.click(codeTab);
|
||||
});
|
||||
|
||||
// Iframe is unmounted, custom code view appears.
|
||||
expect(screen.queryByTestId("html-svg-renderer-iframe")).toBeNull();
|
||||
expect(screen.getByTestId("custom-code-view")).toBeTruthy();
|
||||
|
||||
const previewTab = screen.getByRole("tab", { name: /preview/i });
|
||||
act(() => {
|
||||
fireEvent.click(previewTab);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("html-svg-renderer-iframe")).toBeTruthy();
|
||||
expect(screen.queryByTestId("custom-code-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("locks to the Code tab while the fence is still streaming in", () => {
|
||||
const html = "<html><body>partial";
|
||||
render(
|
||||
<HtmlSvgRenderer language="html" source={html} isIncomplete={true} />,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId("html-svg-renderer");
|
||||
expect(root.getAttribute("data-active-tab")).toBe("code");
|
||||
|
||||
// Preview disabled while streaming so users see tokens, not flicker.
|
||||
const previewTab = screen.getByRole("tab", { name: /preview/i });
|
||||
expect(previewTab.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("HtmlPreview POSTs the assistant source to /api/preview/html before mounting the iframe src", async () => {
|
||||
const html = "<button onclick=\"alert('x')\">go</button>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/preview/html");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.credentials).toBe("same-origin");
|
||||
const parsedBody = JSON.parse(init.body as string) as { source: string };
|
||||
expect(parsedBody.source).toBe(html);
|
||||
// iframe lands on the token URL; no srcdoc.
|
||||
const src = iframe.getAttribute("src") ?? "";
|
||||
expect(src.startsWith("/api/preview/html/")).toBe(true);
|
||||
expect(iframe.getAttribute("srcdoc")).toBeNull();
|
||||
});
|
||||
|
||||
it("wires tabs to their panels with aria-controls / aria-labelledby", async () => {
|
||||
const html = "<html><body>hi</body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
|
||||
const previewTab = screen.getByRole("tab", { name: /preview/i });
|
||||
const codeTab = screen.getByRole("tab", { name: /code/i });
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
|
||||
const controls = previewTab.getAttribute("aria-controls");
|
||||
expect(controls).toBeTruthy();
|
||||
expect(panel.getAttribute("id")).toBe(controls);
|
||||
expect(panel.getAttribute("aria-labelledby")).toBe(
|
||||
previewTab.getAttribute("id"),
|
||||
);
|
||||
|
||||
// Roving tabindex per WAI-ARIA APG tab pattern.
|
||||
expect(previewTab.getAttribute("tabindex")).toBe("0");
|
||||
expect(codeTab.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
it("constrains the SVG preview so a square viewBox does not overflow", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 200\"><circle cx=\"100\" cy=\"100\" r=\"95\" fill=\"red\"/></svg>";
|
||||
render(<HtmlSvgRenderer language="svg" source={svg} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-svg-preview",
|
||||
) as HTMLIFrameElement;
|
||||
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
|
||||
// Cap BOTH dimensions or a square viewBox clips vertically.
|
||||
expect(srcdoc).toContain("max-width:100%");
|
||||
expect(srcdoc).toContain("max-height:100%");
|
||||
expect(srcdoc).toContain("height:100%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIncompleteCodeFence", () => {
|
||||
it("returns lang and body for a fence that has not closed yet", () => {
|
||||
const partial = "```svg\n<svg><circle";
|
||||
const fence = parseIncompleteCodeFence(partial);
|
||||
expect(fence).not.toBeNull();
|
||||
expect(fence?.language).toBe("svg");
|
||||
expect(fence?.source).toBe("<svg><circle");
|
||||
});
|
||||
|
||||
it("strips an in-flight trailing ``` so the partial body does not leak it", () => {
|
||||
const partial = "```html\n<div>hi</div>\n``";
|
||||
const fence = parseIncompleteCodeFence(partial);
|
||||
expect(fence?.source).toBe("<div>hi</div>\n``");
|
||||
});
|
||||
|
||||
it("returns null when the block is not a fence at all", () => {
|
||||
expect(parseIncompleteCodeFence("just text")).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers a final-but-never-closed fence (small LLMs drop the closing ```)", () => {
|
||||
// Regression caught by live Qwen3-0.6B probe: complete body, no closing
|
||||
// ```. Without this path HtmlSvgRenderer would not mount.
|
||||
const finalNoClose =
|
||||
"```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\">" +
|
||||
"<circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"orange\"/></svg>";
|
||||
expect(parseCodeFence(finalNoClose)).toBeNull();
|
||||
const fence = parseIncompleteCodeFence(finalNoClose);
|
||||
expect(fence).not.toBeNull();
|
||||
expect(fence?.language).toBe("svg");
|
||||
expect(fence?.source).toContain("<circle");
|
||||
expect(fence?.source).toContain("</svg>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fence helpers", () => {
|
||||
it("parses a typical markdown code fence", () => {
|
||||
const block = "```python\nprint('hi')\n```";
|
||||
const fence = parseCodeFence(block);
|
||||
expect(fence).not.toBeNull();
|
||||
expect(fence?.language).toBe("python");
|
||||
expect(fence?.source).toBe("print('hi')");
|
||||
});
|
||||
|
||||
it("isSvgFence picks up explicit svg fences and html/xml fences that begin with <svg", () => {
|
||||
expect(isSvgFence({ language: "svg", source: "<svg/>" })).toBe(true);
|
||||
expect(
|
||||
isSvgFence({ language: "html", source: "<svg xmlns='...'></svg>" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSvgFence({
|
||||
language: "xml",
|
||||
source: "<?xml version='1.0'?><svg></svg>",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isSvgFence({ language: "html", source: "<div></div>" })).toBe(false);
|
||||
});
|
||||
|
||||
it("isHtmlFence is true only for non-SVG html fences", () => {
|
||||
expect(isHtmlFence({ language: "html", source: "<div></div>" })).toBe(true);
|
||||
expect(isHtmlFence({ language: "html", source: "<svg></svg>" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isHtmlFence({ language: "python", source: "print()" })).toBe(false);
|
||||
});
|
||||
|
||||
it("non-HTML / non-SVG fences are not handled by the renderer", () => {
|
||||
// markdown-text.tsx invokes HtmlSvgRenderer only when these agree.
|
||||
const fence = parseCodeFence("```python\nprint('hi')\n```");
|
||||
expect(fence).not.toBeNull();
|
||||
if (!fence) return;
|
||||
expect(isSvgFence(fence)).toBe(false);
|
||||
expect(isHtmlFence(fence)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvgSource", () => {
|
||||
it("removes <script>, on* handlers, and javascript: URLs", () => {
|
||||
const malicious = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a href="javascript:alert(1)"><circle cx="5" cy="5" r="4" onload="alert(1)"/></a>
|
||||
<script>alert("pwn")</script>
|
||||
</svg>`;
|
||||
|
||||
const clean = sanitizeSvgSource(malicious).toLowerCase();
|
||||
expect(clean).not.toContain("<script");
|
||||
expect(clean).not.toContain("onload");
|
||||
expect(clean).not.toContain("javascript:");
|
||||
expect(clean).toContain("<circle");
|
||||
});
|
||||
|
||||
it("drops <?xml ... ?> processing instructions", () => {
|
||||
const svg = `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`;
|
||||
const clean = sanitizeSvgSource(svg);
|
||||
expect(clean.startsWith("<?xml")).toBe(false);
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
|
||||
it("keeps inline <style> blocks so class-styled SVG exports still render", () => {
|
||||
// Iframe sandbox="" + default-src 'none' contain <style> blast radius;
|
||||
// diagram exporters relying on class styles outweigh the selector leak.
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><style>.fg{fill:red}</style><rect class="fg"/></svg>`;
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).toContain("<style");
|
||||
expect(clean).toContain(".fg");
|
||||
expect(clean).toContain("<rect");
|
||||
expect(clean).toContain('class="fg"');
|
||||
});
|
||||
|
||||
it("strips style attributes so inline CSS cannot fire url()/@import requests", () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><circle style="background:url(https://evil.example/x)"/></svg>`;
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).toContain("<circle");
|
||||
expect(clean).not.toContain("style=");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("drops <image>/<use> tags so SVG cannot beacon to external URLs", () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image href="https://evil.example/pixel"/><use xlink:href="https://evil.example/use"/></svg>`;
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).not.toContain("<image");
|
||||
expect(clean).not.toContain("<use");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("strips filter/mask/clip-path url(...) attrs that would still fetch", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
|
||||
"<circle filter=\"url(https://evil.example/f)\" mask=\"url(https://evil.example/m)\" clip-path=\"url(https://evil.example/c)\" r=\"10\"/>" +
|
||||
"</svg>";
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).toContain("<circle");
|
||||
expect(clean).not.toContain("filter=");
|
||||
expect(clean).not.toContain("mask=");
|
||||
expect(clean).not.toContain("clip-path=");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("keeps safe same-document fragment hrefs used by textPath/gradients", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
|
||||
"<defs><linearGradient id=\"g1\"/></defs>" +
|
||||
"<text><textPath href=\"#labelPath\">hi</textPath></text>" +
|
||||
"<circle fill=\"url(#g1)\" r=\"10\"/>" +
|
||||
"</svg>";
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
// Fragment hrefs must survive so textPath / gradient refs resolve.
|
||||
expect(clean).toContain("href=\"#labelpath\"");
|
||||
});
|
||||
|
||||
it("strips external-scheme hrefs even though same-doc fragments are kept", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
|
||||
"<a href=\"https://evil.example/exfil\"><circle r=\"10\"/></a>" +
|
||||
"</svg>";
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).not.toContain("evil.example");
|
||||
expect(clean).not.toContain("href=\"https");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { CodeIcon, EyeIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type HtmlSvgLanguage = "html" | "svg";
|
||||
|
||||
export type HtmlSvgRendererProps = {
|
||||
language: HtmlSvgLanguage;
|
||||
source: string;
|
||||
// Optional syntax-highlighted code view; falls back to plain <pre><code>.
|
||||
codeView?: ReactNode;
|
||||
// Partial fence: lock to Code tab and disable toggle.
|
||||
isIncomplete?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PREVIEW_HEIGHT = 500;
|
||||
const POPOUT_HEIGHT_VH = 80;
|
||||
const COPY_RESET_MS = 2000;
|
||||
|
||||
// Belt-and-braces pre-screen; DOMPurify does the real work below.
|
||||
const HEURISTIC_UNSAFE_SVG_RE =
|
||||
/<script[\s>]|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
|
||||
// SVG sanitizer config. Surviving markup is rendered in the sandboxed
|
||||
// SvgPreview iframe below as a second layer.
|
||||
const SVG_PURIFY_CONFIG = {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
// image/use carry href, foreignObject embeds HTML, script/link/meta/iframe/
|
||||
// embed/object are XSS or network surfaces. <style> stays -- the iframe
|
||||
// sandbox + default-src 'none' CSP cap its blast radius.
|
||||
FORBID_TAGS: [
|
||||
"script",
|
||||
"foreignObject",
|
||||
"iframe",
|
||||
"embed",
|
||||
"object",
|
||||
"image",
|
||||
"use",
|
||||
"link",
|
||||
"meta",
|
||||
],
|
||||
// filter / mask / clip-path accept url(...) which fetches at render time;
|
||||
// style allows inline @import / url(...). href / xlink:href stay so safe
|
||||
// same-doc fragments survive -- external schemes pruned in the hook below.
|
||||
FORBID_ATTR: ["style", "filter", "mask", "clip-path"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
// Pin href / xlink:href to same-doc fragments. Done as a hook because
|
||||
// DOMPurify's ALLOWED_URI_REGEXP also rejects presentation attrs (cx, r, ...).
|
||||
// Installed once at module load.
|
||||
const FRAGMENT_HREF_HOOK_TAG = "__unsloth_svg_frag_href__";
|
||||
const URI_ATTRS = new Set(["href", "xlink:href"]);
|
||||
|
||||
if (!(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG]) {
|
||||
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||
if (!URI_ATTRS.has(data.attrName)) return;
|
||||
const value = (data.attrValue ?? "").trim();
|
||||
if (!value.startsWith("#")) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG] =
|
||||
true;
|
||||
}
|
||||
|
||||
/** Strip XML PIs and disallowed nodes from an SVG. */
|
||||
export function sanitizeSvgSource(source: string): string {
|
||||
// Drop XML declarations -- DOMPurify keeps them, some renderers choke.
|
||||
const stripped = source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
|
||||
if (HEURISTIC_UNSAFE_SVG_RE.test(stripped)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug("SVG renderer: stripping unsafe nodes before sanitize");
|
||||
}
|
||||
return DOMPurify.sanitize(stripped, SVG_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
function useCopyState() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flash = useCallback(() => {
|
||||
setCopied(true);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
}, []);
|
||||
|
||||
return { copied, flash };
|
||||
}
|
||||
|
||||
function CopyButton({ source }: { source: string }) {
|
||||
const { copied, flash } = useCopyState();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy code"
|
||||
aria-label="Copy code"
|
||||
className={cn(
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-[10px]",
|
||||
"text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (await copyToClipboard(source)) flash();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type TabKey = "preview" | "code";
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
disabled,
|
||||
icon,
|
||||
id,
|
||||
controls,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
id: string;
|
||||
controls: string;
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={id}
|
||||
aria-controls={controls}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Defence in depth on top of the sanitizer.
|
||||
const SVG_IFRAME_CSP =
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:;";
|
||||
|
||||
function buildSvgSrcDoc(safeSvg: string): string {
|
||||
return [
|
||||
"<!doctype html>",
|
||||
`<meta http-equiv="Content-Security-Policy" content="${SVG_IFRAME_CSP}">`,
|
||||
// Cap both dimensions so a square viewBox does not clip vertically.
|
||||
"<style>html,body{margin:0;padding:0;height:100%;background:white;}",
|
||||
"body{display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;}",
|
||||
"svg{max-width:100%;max-height:100%;width:auto;height:auto;}</style>",
|
||||
safeSvg,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function SvgPreview({ source }: { source: string }) {
|
||||
const srcDoc = useMemo(() => buildSvgSrcDoc(sanitizeSvgSource(source)), [
|
||||
source,
|
||||
]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid="html-svg-renderer-svg-preview"
|
||||
title="SVG preview"
|
||||
srcDoc={srcDoc}
|
||||
// No scripts, no same-origin: SVG cannot touch host document or network.
|
||||
sandbox=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 360,
|
||||
border: "none",
|
||||
display: "block",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Iframe posts its scrollHeight to the parent for auto-sizing. One-way:
|
||||
// no allow-same-origin, so iframe cannot read parent.document.
|
||||
const HTML_PREVIEW_HEIGHT_REPORTER =
|
||||
'<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>';
|
||||
|
||||
// Fallback for when the preview route is unreachable. srcdoc inherits the
|
||||
// host script-src 'self' per CSP3 so inline scripts will NOT run on this
|
||||
// path -- it is layout-only. Interactive surface is the API route below.
|
||||
const HTML_IFRAME_CSP = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src data: blob:",
|
||||
"media-src data: blob:",
|
||||
"font-src data:",
|
||||
"connect-src 'none'",
|
||||
"worker-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
].join("; ");
|
||||
|
||||
function buildHtmlSrcDoc(source: string): string {
|
||||
return [
|
||||
"<!doctype html>",
|
||||
`<meta http-equiv="Content-Security-Policy" content="${HTML_IFRAME_CSP}">`,
|
||||
'<base target="_blank">',
|
||||
source,
|
||||
HTML_PREVIEW_HEIGHT_REPORTER,
|
||||
].join("");
|
||||
}
|
||||
|
||||
// Same-origin route whose response CSP permits inline scripts. POST source,
|
||||
// use returned random-token URL as iframe src.
|
||||
const HTML_PREVIEW_API = "/api/preview/html";
|
||||
|
||||
// Same key as features/auth/session.ts:AUTH_TOKEN_KEY; inlined to avoid cycle.
|
||||
function getStoredAccessToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem("unsloth_auth_token");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PreviewState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; url: string }
|
||||
| { kind: "error" };
|
||||
|
||||
function HtmlPreview({
|
||||
source,
|
||||
popped,
|
||||
onHeightChange,
|
||||
}: {
|
||||
source: string;
|
||||
popped: boolean;
|
||||
onHeightChange?: (h: number | null) => void;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
// POST source -> token URL. srcdoc / data: / blob: inherit the host CSP
|
||||
// per CSP3, so a same-origin route is the only way to unlock inline scripts.
|
||||
const [previewState, setPreviewState] = useState<PreviewState>({
|
||||
kind: "loading",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPreviewState({ kind: "loading" });
|
||||
onHeightChange?.(null);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const token = getStoredAccessToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(HTML_PREVIEW_API, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers,
|
||||
body: JSON.stringify({ source }),
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTML preview HTTP ${r.status}`);
|
||||
return (await r.json()) as { url: string };
|
||||
})
|
||||
.then(({ url }) => {
|
||||
if (cancelled) return;
|
||||
if (typeof url !== "string" || !url.startsWith("/api/preview/html/")) {
|
||||
throw new Error("HTML preview returned an unexpected URL shape");
|
||||
}
|
||||
setPreviewState({ kind: "ready", url });
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setPreviewState({ kind: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [source, onHeightChange]);
|
||||
|
||||
const [autoHeight, setAutoHeight] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
setAutoHeight(null);
|
||||
}, [previewState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
if (e.source !== iframeRef.current?.contentWindow) return;
|
||||
const raw = (e.data as { htmlPreviewHeight?: unknown })
|
||||
?.htmlPreviewHeight;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
const next = Math.max(100, raw);
|
||||
setAutoHeight(next);
|
||||
onHeightChange?.(next);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [onHeightChange]);
|
||||
|
||||
const iframeHeight = popped
|
||||
? "100%"
|
||||
: Math.min(autoHeight ?? DEFAULT_PREVIEW_HEIGHT, DEFAULT_PREVIEW_HEIGHT);
|
||||
|
||||
// Backend unreachable: fall back to srcdoc (static layout, no scripts)
|
||||
// instead of going blank.
|
||||
const errorSrcDoc = useMemo(
|
||||
() => (previewState.kind === "error" ? buildHtmlSrcDoc(source) : null),
|
||||
[previewState.kind, source],
|
||||
);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid="html-svg-renderer-iframe"
|
||||
data-preview-state={previewState.kind}
|
||||
title="HTML preview"
|
||||
src={previewState.kind === "ready" ? previewState.url : "about:blank"}
|
||||
srcDoc={errorSrcDoc ?? undefined}
|
||||
// allow-scripts: inline JS runs (route CSP permits it).
|
||||
// allow-modals: alert / confirm / prompt usable.
|
||||
// allow-popups: <base target="_blank"> links open a tab.
|
||||
// NOT granted:
|
||||
// allow-same-origin / allow-top-navigation -- preview JS cannot
|
||||
// reach parent.document or navigate the host (URL is same-origin
|
||||
// but iframe is treated as opaque).
|
||||
// allow-popups-to-escape-sandbox -- opened tabs inherit sandbox so
|
||||
// window.opener.top.location.* tabnabbing is blocked.
|
||||
sandbox="allow-scripts allow-modals allow-popups"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: iframeHeight,
|
||||
border: "none",
|
||||
display: "block",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HtmlSvgRenderer({
|
||||
language,
|
||||
source,
|
||||
codeView,
|
||||
isIncomplete,
|
||||
}: HtmlSvgRendererProps) {
|
||||
// Lock to Code while streaming so users see tokens, not a flashing preview.
|
||||
const lockedToCode = Boolean(isIncomplete);
|
||||
const [tab, setTab] = useState<TabKey>("preview");
|
||||
const [popped, setPopped] = useState(false);
|
||||
// Lifted from HtmlPreview so the pop-out spacer can match current height.
|
||||
const [htmlHeight, setHtmlHeight] = useState<number | null>(null);
|
||||
|
||||
const activeTab: TabKey = lockedToCode ? "code" : tab;
|
||||
|
||||
const reactId = useId();
|
||||
const previewTabId = `${reactId}-tab-preview`;
|
||||
const codeTabId = `${reactId}-tab-code`;
|
||||
const previewPanelId = `${reactId}-panel-preview`;
|
||||
const codePanelId = `${reactId}-panel-code`;
|
||||
|
||||
// Escape key exits the popout view.
|
||||
useEffect(() => {
|
||||
if (!popped) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setPopped(false);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [popped]);
|
||||
|
||||
const codeFallback = useMemo(
|
||||
() =>
|
||||
codeView ?? (
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||
<code>{source}</code>
|
||||
</pre>
|
||||
),
|
||||
[codeView, source],
|
||||
);
|
||||
|
||||
const onHtmlHeight = useCallback((h: number | null) => setHtmlHeight(h), []);
|
||||
|
||||
const preview =
|
||||
language === "svg" ? (
|
||||
<SvgPreview source={source} />
|
||||
) : (
|
||||
<HtmlPreview
|
||||
source={source}
|
||||
popped={popped}
|
||||
onHeightChange={onHtmlHeight}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewLabel = language === "svg" ? "SVG preview" : "HTML preview";
|
||||
// Match live iframe height so popping out a short preview does not leave
|
||||
// a 500px hole. Falls back to DEFAULT before the first height post.
|
||||
const popoutSpacerHeight = Math.min(
|
||||
htmlHeight ?? DEFAULT_PREVIEW_HEIGHT,
|
||||
DEFAULT_PREVIEW_HEIGHT,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="html-svg-renderer"
|
||||
data-language={language}
|
||||
data-active-tab={activeTab}
|
||||
className="my-4 overflow-hidden rounded-xl border border-border"
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={`${language.toUpperCase()} fence view`}
|
||||
className="flex items-center justify-between gap-2 border-b border-border bg-muted/40 px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5">
|
||||
<TabButton
|
||||
active={activeTab === "preview"}
|
||||
disabled={lockedToCode}
|
||||
icon={<EyeIcon className="size-3.5" />}
|
||||
id={previewTabId}
|
||||
controls={previewPanelId}
|
||||
label="Preview"
|
||||
onSelect={() => setTab("preview")}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === "code"}
|
||||
icon={<CodeIcon className="size-3.5" />}
|
||||
id={codeTabId}
|
||||
controls={codePanelId}
|
||||
label="Code"
|
||||
onSelect={() => setTab("code")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{activeTab === "code" && !codeView ? (
|
||||
// Custom code views ship their own copy/download chrome
|
||||
// (CodeBlockActions in markdown-text.tsx); only show Copy
|
||||
// when we are using the plain <pre> fallback.
|
||||
<CopyButton source={source} />
|
||||
) : null}
|
||||
{activeTab === "preview" && language === "html" ? (
|
||||
<button
|
||||
type="button"
|
||||
title={popped ? "Exit pop out" : "Pop out preview"}
|
||||
aria-label={popped ? "Exit pop out" : "Pop out preview"}
|
||||
className={cn(
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-[10px]",
|
||||
"text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
)}
|
||||
onClick={() => setPopped((p) => !p)}
|
||||
>
|
||||
{popped ? (
|
||||
<Minimize2Icon className="size-4" />
|
||||
) : (
|
||||
<Maximize2Icon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="html-svg-renderer-body" className="bg-background">
|
||||
{activeTab === "preview" ? (
|
||||
popped && language === "html" ? (
|
||||
<>
|
||||
{/* Keep layout stable behind the modal. */}
|
||||
<div
|
||||
style={{ height: popoutSpacerHeight }}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="HTML preview pop out"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setPopped(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setPopped(false)}
|
||||
title="Exit pop out (Esc)"
|
||||
>
|
||||
<Minimize2Icon className="size-4" />
|
||||
Exit pop out
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background"
|
||||
style={{ maxHeight: `${POPOUT_HEIGHT_VH}vh` }}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={previewPanelId}
|
||||
aria-labelledby={previewTabId}
|
||||
aria-label={previewLabel}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={codePanelId}
|
||||
aria-labelledby={codeTabId}
|
||||
data-testid="html-svg-renderer-code"
|
||||
className="min-w-0"
|
||||
>
|
||||
{codeFallback}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Fence helpers (exported for tests + reuse from markdown-text.tsx) ----
|
||||
|
||||
export type CodeFenceInfo = {
|
||||
language: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
// Open fence: no closing ``` yet. Lets HtmlSvgRenderer mount mid-stream
|
||||
// (otherwise the markdown pipeline falls through until the close arrives).
|
||||
const OPEN_CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*)$/;
|
||||
|
||||
export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
|
||||
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
|
||||
if (!match) return null;
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a code fence that may still be streaming (no closing ``` yet). */
|
||||
export function parseIncompleteCodeFence(
|
||||
blockContent: string,
|
||||
): CodeFenceInfo | null {
|
||||
const match = blockContent.match(OPEN_CODE_FENCE_RE);
|
||||
if (!match) return null;
|
||||
// Strip a trailing partial ``` so mid-close captures do not leak it.
|
||||
const body = match[2].replace(/\r?\n?```\s*$/, "");
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: body,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSvgFence(fence: CodeFenceInfo): boolean {
|
||||
const lang = fence.language?.toLowerCase() ?? "";
|
||||
if (lang === "svg") return true;
|
||||
if (lang === "xml" || lang === "html") {
|
||||
const trimmed = fence.source.trimStart();
|
||||
if (trimmed.startsWith("<svg")) return true;
|
||||
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isHtmlFence(fence: CodeFenceInfo): boolean {
|
||||
const lang = fence.language?.toLowerCase() ?? "";
|
||||
return lang === "html" && !isSvgFence(fence);
|
||||
}
|
||||
|
|
@ -12,12 +12,20 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { createCodePlugin } from "./code-plugin";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { AudioPlayer } from "./audio-player";
|
||||
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
|
||||
import {
|
||||
HtmlSvgRenderer,
|
||||
isHtmlFence,
|
||||
isSvgFence,
|
||||
parseCodeFence,
|
||||
parseIncompleteCodeFence,
|
||||
type CodeFenceInfo,
|
||||
} from "./html-svg-renderer";
|
||||
|
||||
const math = createMathPlugin({ singleDollarTextMath: true });
|
||||
const code = createCodePlugin({
|
||||
|
|
@ -48,34 +56,16 @@ const STREAMDOWN_COMPONENTS = {
|
|||
};
|
||||
const COPY_RESET_MS = 2000;
|
||||
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
|
||||
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
const ACTION_PANEL_CLASS =
|
||||
"pointer-events-auto flex shrink-0 items-center gap-1";
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
type CodeFence = {
|
||||
language: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
function getMermaidSource(blockContent: string): string | null {
|
||||
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
|
||||
return source && source.length > 0 ? source : null;
|
||||
}
|
||||
|
||||
function getCodeFence(blockContent: string): CodeFence | null {
|
||||
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function getCodeFilename(language: string | null) {
|
||||
const extByLanguage: Record<string, string> = {
|
||||
bash: "sh",
|
||||
|
|
@ -106,135 +96,6 @@ function getCodeFilename(language: string | null) {
|
|||
return `snippet.${ext}`;
|
||||
}
|
||||
|
||||
function isSvgFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
if (lang === "svg") return true;
|
||||
if (lang === "xml" || lang === "html") {
|
||||
const trimmed = codeFence.source.trimStart();
|
||||
// Match <svg directly or <?xml ...?> followed by <svg
|
||||
if (trimmed.startsWith("<svg")) return true;
|
||||
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHtmlFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
return lang === "html" && !isSvgFence(codeFence);
|
||||
}
|
||||
|
||||
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
|
||||
function sanitizeSvg(source: string): string | null {
|
||||
if (UNSAFE_SVG_RE.test(source)) return null;
|
||||
// Strip XML declaration (<?xml ...?>) -- not needed for data URI
|
||||
// rendering and can cause issues with some renderers.
|
||||
return source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
|
||||
}
|
||||
|
||||
function SvgPreview({ source }: { source: string }) {
|
||||
const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
|
||||
return (
|
||||
<div className="mt-2 flex justify-center rounded-lg border border-border bg-white p-4 dark:bg-neutral-100">
|
||||
<img
|
||||
src={dataUri}
|
||||
alt="SVG preview"
|
||||
style={{ maxWidth: "100%", maxHeight: 512 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
|
||||
const HTML_PREVIEW_MAX_HEIGHT = 800;
|
||||
|
||||
function HtmlPreview({ source }: { source: string }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
|
||||
const [enlarged, setEnlarged] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
if (e.source !== iframeRef.current?.contentWindow) return;
|
||||
if (typeof e.data?.htmlPreviewHeight === "number") {
|
||||
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enlarged) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setEnlarged(false);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [enlarged]);
|
||||
|
||||
const resizeScript = `<script>new ResizeObserver(()=>{
|
||||
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
|
||||
}).observe(document.documentElement);</script>`;
|
||||
|
||||
const srcDoc = source + resizeScript;
|
||||
|
||||
if (enlarged) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
|
||||
{/* Placeholder keeps layout stable while overlay is shown */}
|
||||
</div>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setEnlarged(false)}
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<Minimize2Icon className="size-4" />
|
||||
Exit fullscreen
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
|
||||
onClick={() => setEnlarged(true)}
|
||||
title="Enlarge preview"
|
||||
>
|
||||
<Maximize2Icon className="size-4" />
|
||||
</button>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: "100%", height, border: "none", display: "block" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -345,10 +206,26 @@ function CodeBlockActions({
|
|||
);
|
||||
}
|
||||
|
||||
function renderHighlightedCode(props: BlockProps, codeFence: CodeFenceInfo) {
|
||||
return (
|
||||
<div className="relative isolate">
|
||||
<Block {...props} />
|
||||
<CodeBlockActions
|
||||
disabled={props.isIncomplete}
|
||||
language={codeFence.language}
|
||||
source={codeFence.source}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamdownBlock(props: BlockProps) {
|
||||
const hasMermaidFence = props.content.includes("```mermaid");
|
||||
const mermaidSource = getMermaidSource(props.content);
|
||||
const codeFence = getCodeFence(props.content);
|
||||
// Fall back to parseIncompleteCodeFence for both still-streaming AND
|
||||
// finished-but-unclosed fences (small LLMs routinely drop the closing ```).
|
||||
const codeFence =
|
||||
parseCodeFence(props.content) ?? parseIncompleteCodeFence(props.content);
|
||||
|
||||
if (props.isIncomplete && hasMermaidFence) {
|
||||
return (
|
||||
|
|
@ -358,27 +235,6 @@ function StreamdownBlock(props: BlockProps) {
|
|||
);
|
||||
}
|
||||
|
||||
if (props.isIncomplete && codeFence && isSvgFence(codeFence)) {
|
||||
return (
|
||||
<div className="relative isolate">
|
||||
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
|
||||
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
<code>{codeFence.source}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
|
||||
return (
|
||||
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
|
||||
Loading preview...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mermaidSource) {
|
||||
return (
|
||||
<div className="relative isolate">
|
||||
|
|
@ -389,22 +245,20 @@ function StreamdownBlock(props: BlockProps) {
|
|||
}
|
||||
|
||||
if (codeFence) {
|
||||
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
|
||||
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
|
||||
return (
|
||||
<>
|
||||
<div className="relative isolate">
|
||||
<Block {...props} />
|
||||
<CodeBlockActions
|
||||
disabled={props.isIncomplete}
|
||||
language={codeFence.language}
|
||||
source={codeFence.source}
|
||||
/>
|
||||
</div>
|
||||
{svgSource && <SvgPreview source={svgSource} />}
|
||||
{htmlSource && <HtmlPreview source={htmlSource} />}
|
||||
</>
|
||||
);
|
||||
const svg = isSvgFence(codeFence);
|
||||
const html = !svg && isHtmlFence(codeFence);
|
||||
if (svg || html) {
|
||||
// Tabbed Preview/Code; locks to Code while streaming.
|
||||
return (
|
||||
<HtmlSvgRenderer
|
||||
language={svg ? "svg" : "html"}
|
||||
source={codeFence.source}
|
||||
codeView={renderHighlightedCode(props, codeFence)}
|
||||
isIncomplete={props.isIncomplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderHighlightedCode(props, codeFence);
|
||||
}
|
||||
|
||||
return <Block {...props} />;
|
||||
|
|
|
|||
30
studio/frontend/src/test-setup/setup.ts
Normal file
30
studio/frontend/src/test-setup/setup.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
// jsdom shims: ResizeObserver + URL.createObjectURL.
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
let __blobCounter = 0;
|
||||
if (typeof URL.createObjectURL !== "function") {
|
||||
URL.createObjectURL = ((_blob: Blob) =>
|
||||
`blob:jsdom/${++__blobCounter}`) as typeof URL.createObjectURL;
|
||||
}
|
||||
if (typeof URL.revokeObjectURL !== "function") {
|
||||
URL.revokeObjectURL = (() => undefined) as typeof URL.revokeObjectURL;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
22
studio/frontend/vitest.config.ts
Normal file
22
studio/frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
css: false,
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
setupFiles: ["./src/test-setup/setup.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue