mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(artifacts): authenticate isolated HTML preview resources (#6701)
* fix(artifacts): authenticate isolated HTML preview resources * docs(artifacts): retain precise path guidance within size budget * fix: preserve HTML preview auth and rendering semantics * fix(artifacts): quote rewritten CSS URLs * docs: keep merged artifact guidance within size limit --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
a6b3552362
commit
8008a6195f
12
README.md
12
README.md
@ -2467,6 +2467,18 @@ CSV and TSV artifacts open as tables in the artifact panel and in a separate win
|
||||
|
||||
HTML artifact previews resolve relative assets from the artifact directory unless the document contains a real `<base>` element. Base-tag examples in comments or script text do not change that resolution.
|
||||
|
||||
With authentication enabled, static resources in the same thread's artifact route
|
||||
(images, responsive images, classic scripts and CSS, including nested imports)
|
||||
are fetched by the parent and inlined without weakening the opaque iframe sandbox.
|
||||
Preparation has resource, byte, CSS-depth and time limits and reports partial
|
||||
previews. Dynamic JavaScript requests, ES module imports, nested iframes and
|
||||
navigation remain subject to sandbox restrictions; see the
|
||||
[frontend preview notes](frontend/README.md#development-notes) for the supported
|
||||
scope and limits.
|
||||
Optional resource failures, including 401 responses, keep the conversation open
|
||||
with a partial-preview notice. HTML doctypes and doctype-less rendering modes
|
||||
are preserved, and CSS references inside inline SVG styles are prepared too.
|
||||
|
||||
Markdown report previews in the artifact panel and separate window resolve local
|
||||
images from the report's own directory and conversation. Both relative paths
|
||||
such as `./charts/summary.png` and absolute `/mnt/...` paths are supported;
|
||||
|
||||
@ -143,6 +143,25 @@ src/
|
||||
|
||||
## Development Notes
|
||||
|
||||
HTML artifact previews keep an opaque iframe sandbox. Static sibling images,
|
||||
responsive image sources, classic scripts, stylesheets, CSS imports and CSS
|
||||
`url()` resources in the same thread's artifact route are fetched by the
|
||||
authenticated parent and inlined as data URLs. Existing external
|
||||
resources and external `<base>` URLs retain their browser behavior. Resource
|
||||
preparation is capped at 32 resources, 4 MiB per resource, 16 MiB total, eight
|
||||
nested CSS imports, 24 MiB of expanded references and ten seconds; unavailable or oversized resources show a
|
||||
partial-preview notice. Switching artifacts cancels preparation and releases
|
||||
the generated document Blob URL.
|
||||
Resource 401 responses remain local to the partial preview; only the document
|
||||
load uses the application's login-redirect policy. Preparation preserves the
|
||||
original doctype (including its absence) and handles inline SVG styles.
|
||||
|
||||
This supports static generated reports rather than a complete website runtime.
|
||||
ES module imports, resources created dynamically by JavaScript, `fetch()`/XHR,
|
||||
iframes and navigation still follow opaque-origin browser restrictions and do
|
||||
not receive the parent session. View source or download the files when a report
|
||||
requires those features.
|
||||
|
||||
- Uses pnpm workspaces (see `packageManager` in package.json)
|
||||
- Webpack is the default development bundler until the upstream Turbopack PostCSS worker leak is fixed in a stable Next.js release (#5132). Set `DEER_FLOW_DEV_BUNDLER=turbo` to opt in to Turbopack for local diagnosis, or `DEER_FLOW_DEV_BUNDLER=webpack` to select Webpack explicitly. Reconsider the default after the stable fix is verified on macOS arm64 and Linux.
|
||||
- Environment validation can be skipped with `SKIP_ENV_VALIDATION=1` (useful for Docker)
|
||||
|
||||
@ -10,18 +10,20 @@ failure/replay-gap paths. Draft and confirmation regressions live in
|
||||
Answer details use `workspace/message-details` descriptors; skill run scoping
|
||||
is documented in `docs/skill-usage-ui.md`.
|
||||
|
||||
Encode raw paths preserving literal percent escapes. Decode Markdown destinations
|
||||
once; match image names to raw paths. Preserve sanitization; key Streamdown by
|
||||
file/thread/mock context plus scroll key.
|
||||
File-type detection uses the basename so extensionless `Dockerfile` and
|
||||
`Makefile` artifacts remain recognizable under nested or dotted directories.
|
||||
HTML preview base detection skips DOM construction without case-insensitive `base`
|
||||
text; otherwise an inert template distinguishes real tags from comments, scripts
|
||||
and nested templates without rewriting source. Tests: `preview-base.dom.test.ts`
|
||||
(happy-dom); textarea/title RCDATA needs real-browser checks due to parser gaps.
|
||||
Other preview helpers retain the node test environment.
|
||||
Language-map membership checks only own properties; inherited names such as
|
||||
`constructor` and `__proto__` must keep the unknown-file download fallback.
|
||||
Encode raw paths preserving literal percent escapes. Decode Markdown once;
|
||||
match image names to raw paths.
|
||||
Sanitize URLs; key Streamdown by file/thread/mock context and scroll key.
|
||||
Detect file types by basename, including Dockerfile/Makefile.
|
||||
Language maps use own properties; `constructor`/`__proto__` keep fallback.
|
||||
HTML bases skip DOM without base text; inert templates ignore comments/scripts
|
||||
and templates. `preview-base.dom.test.ts` (happy-dom); RCDATA needs
|
||||
browser checks; other helpers use node.
|
||||
HTML assets: credential GET, no 401 navigation; current-thread only, no redirects,
|
||||
bound bytes/count/CSS depth/time/expansion; keep doctype, rewrite SVG style.
|
||||
Keep opaque sandbox/data URLs; external refs stay browser-loaded.
|
||||
Abort stale work; revoke URLs; gate content/URL/scroll.
|
||||
Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`,
|
||||
`tests/e2e/artifact-preview.spec.ts`.
|
||||
|
||||
1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming. Voice transcript joining separates every adjacent pair of trimmed word segments, preserves CJK joins and provider whitespace, and attaches leading punctuation/percent signs while retaining spaces after sentence punctuation. Regression cases live in `tests/unit/core/voice-input/speech-recognition.test.ts`.
|
||||
2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream.
|
||||
|
||||
@ -6,9 +6,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defaultUrlTransform, type UrlTransform } from "streamdown";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
|
||||
import {
|
||||
getTabularDelimiter,
|
||||
appendHtmlPreviewBaseHref,
|
||||
appendHtmlPreviewScrollRestoration,
|
||||
createHtmlPreviewScrollKey,
|
||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
@ -19,6 +19,7 @@ import {
|
||||
urlOfArtifact,
|
||||
} from "@/core/artifacts/utils";
|
||||
import { extractCitationSources } from "@/core/citations/sources";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
toStreamdownComponents,
|
||||
@ -146,6 +147,7 @@ export function ArtifactFilePreview({
|
||||
truncated?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { filepath, threadId, isMock } = artifact ?? {};
|
||||
const transformUrl = useCallback<UrlTransform>(
|
||||
(src, key, node) =>
|
||||
@ -164,7 +166,18 @@ export function ArtifactFilePreview({
|
||||
() => createHtmlPreviewScrollKey(scrollKey),
|
||||
[scrollKey],
|
||||
);
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const previewIdentity = useMemo(
|
||||
() => ({ content, language, scrollKey, url }),
|
||||
[content, language, scrollKey, url],
|
||||
);
|
||||
const [htmlPreview, setHtmlPreview] = useState<{
|
||||
identity: typeof previewIdentity;
|
||||
url?: string;
|
||||
failedResources?: number;
|
||||
error?: boolean;
|
||||
}>();
|
||||
const currentPreview =
|
||||
htmlPreview?.identity === previewIdentity ? htmlPreview : undefined;
|
||||
const citationSources = useMemo(
|
||||
() =>
|
||||
language === "markdown" ? extractCitationSources(content ?? "") : [],
|
||||
@ -216,24 +229,39 @@ export function ArtifactFilePreview({
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
setHtmlPreviewUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContent = appendHtmlPreviewScrollRestoration(
|
||||
appendHtmlPreviewBaseHref(content ?? "", url),
|
||||
scrollKey,
|
||||
const controller = new AbortController();
|
||||
let objectUrl: string | undefined;
|
||||
void prepareHtmlPreview(content ?? "", url, controller.signal).then(
|
||||
(prepared) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const previewContent = appendHtmlPreviewScrollRestoration(
|
||||
prepared.content,
|
||||
scrollKey,
|
||||
);
|
||||
objectUrl = URL.createObjectURL(
|
||||
new Blob([previewContent], { type: "text/html;charset=utf-8" }),
|
||||
);
|
||||
setHtmlPreview({
|
||||
identity: previewIdentity,
|
||||
url: objectUrl,
|
||||
failedResources: prepared.failedResources,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (!controller.signal.aborted)
|
||||
setHtmlPreview({ identity: previewIdentity, error: true });
|
||||
},
|
||||
);
|
||||
const blob = new Blob([previewContent], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [content, language, scrollKey, url]);
|
||||
}, [content, language, scrollKey, url, previewIdentity]);
|
||||
|
||||
const delimiter = getTabularDelimiter(language);
|
||||
if (delimiter !== null) {
|
||||
@ -266,18 +294,29 @@ export function ArtifactFilePreview({
|
||||
}
|
||||
if (language === "html") {
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="size-full"
|
||||
title="Artifact preview"
|
||||
// allow-scripts is needed for the scroll-restoration injected
|
||||
// script (appendHtmlPreviewScrollRestoration) which communicates
|
||||
// via postMessage. allow-same-origin is deliberately omitted: the
|
||||
// opaque origin prevents access to parent.document and cookies,
|
||||
// and postMessage(..., "*") works fine from it.
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={htmlPreviewUrl}
|
||||
/>
|
||||
<div className="flex size-full flex-col">
|
||||
{(!currentPreview?.url || !!currentPreview.failedResources) && (
|
||||
<p className="text-muted-foreground px-4 py-2 text-sm" role="status">
|
||||
{currentPreview?.error
|
||||
? t.artifactPreview.previewFailed
|
||||
: currentPreview?.failedResources
|
||||
? t.artifactPreview.resourcesUnavailable
|
||||
: t.artifactPreview.loadingPreview}
|
||||
</p>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="min-h-0 w-full flex-1"
|
||||
title="Artifact preview"
|
||||
// allow-scripts is needed for the scroll-restoration injected
|
||||
// script (appendHtmlPreviewScrollRestoration) which communicates
|
||||
// via postMessage. allow-same-origin is deliberately omitted: the
|
||||
// opaque origin prevents access to parent.document and cookies,
|
||||
// and postMessage(..., "*") works fine from it.
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={currentPreview?.url}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
366
frontend/src/core/artifacts/html-assets.ts
Normal file
366
frontend/src/core/artifacts/html-assets.ts
Normal file
@ -0,0 +1,366 @@
|
||||
import { appendHtmlPreviewBaseHref } from "./preview";
|
||||
|
||||
const MAX_RESOURCES = 32;
|
||||
const MAX_RESOURCE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_CSS_DEPTH = 8;
|
||||
const MAX_INLINE_BYTES = 24 * 1024 * 1024;
|
||||
const EMPTY_RESOURCE = "data:,";
|
||||
|
||||
/** Locate CSS URL tokens without treating comments or ordinary strings as URLs. */
|
||||
async function rewriteCss(
|
||||
css: string,
|
||||
replace: (value: string, stylesheet: boolean) => Promise<string>,
|
||||
) {
|
||||
const quoteUrl = (value: string) =>
|
||||
`"${value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/[\r\n\f]/g, "\\A ")}"`;
|
||||
const tokens = /\/\*|["']|\burl\s*\(|@import\s+/gi;
|
||||
let result = "";
|
||||
let copied = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const stringEnd = (start: number, quote: string) => {
|
||||
let end = start;
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === "\\") end++;
|
||||
else if (css[end] === quote) break;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
while ((match = tokens.exec(css))) {
|
||||
const token = match[0];
|
||||
if (token === "/*") {
|
||||
const end = css.indexOf("*/", tokens.lastIndex);
|
||||
tokens.lastIndex = end < 0 ? css.length : end + 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '"' || token === "'") {
|
||||
tokens.lastIndex = stringEnd(tokens.lastIndex, token) + 1;
|
||||
continue;
|
||||
}
|
||||
const imported = token.toLowerCase().startsWith("@import");
|
||||
let start = tokens.lastIndex;
|
||||
while (/\s/.test(css[start] ?? "")) start++;
|
||||
if (imported && css.slice(start, start + 3).toLowerCase() === "url") {
|
||||
const opening = /^url\s*\(/i.exec(css.slice(start));
|
||||
if (!opening) continue;
|
||||
start += opening[0].length;
|
||||
while (/\s/.test(css[start] ?? "")) start++;
|
||||
} else if (imported && css[start] !== '"' && css[start] !== "'") {
|
||||
continue;
|
||||
}
|
||||
const quote = css[start] === '"' || css[start] === "'" ? css[start]! : null;
|
||||
const valueStart = quote ? start + 1 : start;
|
||||
let end = quote ? stringEnd(valueStart, quote) : valueStart;
|
||||
if (!quote) {
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === "\\") end++;
|
||||
else if (css[end] === ")") break;
|
||||
}
|
||||
}
|
||||
if (end >= css.length) break;
|
||||
const raw = css.slice(valueStart, end);
|
||||
const encoded = quote ? raw : raw.trim();
|
||||
const value = encoded.replace(
|
||||
/\\([0-9a-f]{1,6}\s?|\r\n|[\n\r\f]|.)/gi,
|
||||
(_escape, escaped: string) => {
|
||||
if (/^[0-9a-f]/i.test(escaped)) {
|
||||
const code = Number.parseInt(escaped.trim(), 16);
|
||||
return String.fromCodePoint(
|
||||
code === 0 || code > 0x10ffff ? 0xfffd : code,
|
||||
);
|
||||
}
|
||||
return /^[\n\r\f]/.test(escaped) ? "" : escaped;
|
||||
},
|
||||
);
|
||||
const replacement = await replace(value, imported);
|
||||
const formattedReplacement =
|
||||
replacement === value || quote
|
||||
? replacement === value
|
||||
? raw
|
||||
: replacement
|
||||
: quoteUrl(replacement);
|
||||
result += css.slice(copied, valueStart) + formattedReplacement;
|
||||
copied = end;
|
||||
tokens.lastIndex = end + (quote ? 1 : 0);
|
||||
}
|
||||
return result + css.slice(copied);
|
||||
}
|
||||
|
||||
/** Follow srcset URL boundaries, including commas within data URLs. */
|
||||
async function rewriteSrcset(
|
||||
value: string,
|
||||
replace: (url: string) => Promise<string>,
|
||||
) {
|
||||
let result = "";
|
||||
let copied = 0;
|
||||
let position = 0;
|
||||
while (position < value.length) {
|
||||
while (/[\s,]/.test(value[position] ?? "")) position++;
|
||||
const start = position;
|
||||
while (position < value.length && !/\s/.test(value[position]!)) position++;
|
||||
let end = position;
|
||||
while (value[end - 1] === ",") end--;
|
||||
if (end > start) {
|
||||
result +=
|
||||
value.slice(copied, start) + (await replace(value.slice(start, end)));
|
||||
copied = end;
|
||||
}
|
||||
if (end < position) continue;
|
||||
let parentheses = 0;
|
||||
while (position < value.length) {
|
||||
const character = value[position++];
|
||||
if (character === "(") parentheses++;
|
||||
if (character === ")") parentheses--;
|
||||
if (character === "," && parentheses === 0) break;
|
||||
}
|
||||
}
|
||||
return result + value.slice(copied);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only this thread's artifact resources with the parent's session. The
|
||||
* sandbox remains opaque; data URLs work across that origin boundary whereas
|
||||
* parent-owned Blob URLs are blocked by Chromium's storage partition checks.
|
||||
*/
|
||||
export async function prepareHtmlPreview(
|
||||
content: string,
|
||||
artifactUrl?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ content: string; failedResources: number }> {
|
||||
const unchanged = {
|
||||
content: appendHtmlPreviewBaseHref(content, artifactUrl),
|
||||
failedResources: 0,
|
||||
};
|
||||
if (!artifactUrl) return unchanged;
|
||||
const source = new URL(artifactUrl, window.location.href);
|
||||
const route = /^(.*\/api\/threads\/[^/]+\/artifacts\/)/.exec(
|
||||
source.pathname,
|
||||
)?.[1];
|
||||
// Public demos and non-thread document previews retain their existing path.
|
||||
if (!route || !["http:", "https:"].includes(source.protocol))
|
||||
return unchanged;
|
||||
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal?.reason);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
const resources = new Map<string, string>();
|
||||
const loading = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
let failedResources = 0;
|
||||
let inlineBytes = 0;
|
||||
|
||||
function inline(value: string) {
|
||||
inlineBytes += value.length;
|
||||
if (inlineBytes > MAX_INLINE_BYTES) {
|
||||
failedResources++;
|
||||
return EMPTY_RESOURCE;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function localUrl(value: string, base: string) {
|
||||
if (!value || value.startsWith("#")) return null;
|
||||
const target = new URL(value, base);
|
||||
if (target.origin !== source.origin || !target.pathname.startsWith(route!))
|
||||
return null;
|
||||
if (target.username || target.password) return null;
|
||||
try {
|
||||
if (
|
||||
target.pathname
|
||||
.slice(route!.length)
|
||||
.split("/")
|
||||
.some((segment) => /[/\\\u0000]/.test(decodeURIComponent(segment)))
|
||||
)
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function resource(
|
||||
value: string,
|
||||
base: string,
|
||||
stylesheet = false,
|
||||
depth = 0,
|
||||
resolveRelative = false,
|
||||
integrity?: string,
|
||||
): Promise<string> {
|
||||
if (!value || value.startsWith("#")) return value;
|
||||
let target: URL | null;
|
||||
try {
|
||||
target = localUrl(value, base);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
if (!target) {
|
||||
// An inlined stylesheet has no original URL of its own. Keep resources
|
||||
// outside the allowed route browser-loaded at their original location.
|
||||
return resolveRelative ? new URL(value, base).href : value;
|
||||
}
|
||||
const fragment = target.hash;
|
||||
target.hash = "";
|
||||
const key = `${stylesheet ? "css:" : "asset:"}${target.href}:${integrity ?? ""}`;
|
||||
const cached = resources.get(key);
|
||||
if (cached !== undefined)
|
||||
return inline(cached + (cached === EMPTY_RESOURCE ? "" : fragment));
|
||||
if (
|
||||
loading.has(key) ||
|
||||
resources.size + loading.size >= MAX_RESOURCES ||
|
||||
totalBytes >= MAX_TOTAL_BYTES ||
|
||||
inlineBytes >= MAX_INLINE_BYTES ||
|
||||
controller.signal.aborted ||
|
||||
depth > MAX_CSS_DEPTH
|
||||
) {
|
||||
failedResources++;
|
||||
return EMPTY_RESOURCE;
|
||||
}
|
||||
loading.add(key);
|
||||
try {
|
||||
controller.signal.throwIfAborted();
|
||||
// A failed optional asset must not trigger the API client's page-wide
|
||||
// login redirect. The top-level document load keeps that auth policy.
|
||||
const response = await globalThis.fetch(target.href, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
...(integrity ? { integrity } : {}),
|
||||
});
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error("Artifact resource unavailable");
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value: chunk } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += chunk.byteLength;
|
||||
totalBytes += chunk.byteLength;
|
||||
if (bytes > MAX_RESOURCE_BYTES || totalBytes > MAX_TOTAL_BYTES)
|
||||
throw new Error("Artifact preview resource limit exceeded");
|
||||
chunks.push(new Uint8Array(chunk));
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
let blob = new Blob(chunks, {
|
||||
type:
|
||||
response.headers.get("Content-Type") ?? "application/octet-stream",
|
||||
});
|
||||
if (stylesheet) {
|
||||
const css = await rewriteCss(await blob.text(), (reference, imported) =>
|
||||
resource(reference, target.href, imported, depth + 1, true),
|
||||
);
|
||||
blob = new Blob([css], { type: "text/css;charset=utf-8" });
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
const encodedBytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let binary = "";
|
||||
for (let start = 0; start < encodedBytes.length; start += 0x8000) {
|
||||
binary += String.fromCharCode(
|
||||
...encodedBytes.subarray(start, start + 0x8000),
|
||||
);
|
||||
}
|
||||
const url = `data:${blob.type};base64,${btoa(binary)}`;
|
||||
resources.set(key, url);
|
||||
return inline(url + fragment);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
failedResources++;
|
||||
resources.set(key, EMPTY_RESOURCE);
|
||||
return EMPTY_RESOURCE;
|
||||
} finally {
|
||||
loading.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const document = new DOMParser().parseFromString(content, "text/html");
|
||||
const base = new URL(
|
||||
document.querySelector("base[href]")?.getAttribute("href") ?? source.href,
|
||||
source.href,
|
||||
).href;
|
||||
for (const element of document.querySelectorAll(
|
||||
"[src], [srcset], [poster], link[href], style, [style]",
|
||||
)) {
|
||||
for (const attribute of ["src", "poster"]) {
|
||||
const value = element.getAttribute(attribute);
|
||||
if (value && element.tagName !== "IFRAME") {
|
||||
const rewritten = await resource(
|
||||
value,
|
||||
base,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
element.getAttribute("integrity") ?? undefined,
|
||||
);
|
||||
element.setAttribute(attribute, rewritten);
|
||||
if (rewritten !== value) element.removeAttribute("integrity");
|
||||
}
|
||||
}
|
||||
const srcset = element.getAttribute("srcset");
|
||||
if (srcset)
|
||||
element.setAttribute(
|
||||
"srcset",
|
||||
await rewriteSrcset(srcset, (reference) => resource(reference, base)),
|
||||
);
|
||||
if (
|
||||
element.tagName === "LINK" &&
|
||||
element
|
||||
.getAttribute("rel")
|
||||
?.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.includes("stylesheet")
|
||||
) {
|
||||
const value = element.getAttribute("href");
|
||||
if (value) {
|
||||
const rewritten = await resource(
|
||||
value,
|
||||
base,
|
||||
true,
|
||||
0,
|
||||
false,
|
||||
element.getAttribute("integrity") ?? undefined,
|
||||
);
|
||||
element.setAttribute("href", rewritten);
|
||||
// Fetch checked the original bytes. Rewritten CSS has a new hash.
|
||||
if (rewritten !== value) element.removeAttribute("integrity");
|
||||
}
|
||||
}
|
||||
if (element.localName === "style")
|
||||
element.textContent = await rewriteCss(
|
||||
element.textContent ?? "",
|
||||
(reference, imported) => resource(reference, base, imported),
|
||||
);
|
||||
const style = element.getAttribute("style");
|
||||
if (style)
|
||||
element.setAttribute(
|
||||
"style",
|
||||
await rewriteCss(style, (reference, imported) =>
|
||||
resource(reference, base, imported),
|
||||
),
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
content: appendHtmlPreviewBaseHref(
|
||||
Array.from(document.childNodes, (node) =>
|
||||
node === document.documentElement
|
||||
? document.documentElement.outerHTML
|
||||
: new XMLSerializer().serializeToString(node),
|
||||
).join(""),
|
||||
artifactUrl,
|
||||
),
|
||||
failedResources,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
@ -297,6 +297,9 @@ export const enUS: Translations = {
|
||||
},
|
||||
|
||||
artifactPreview: {
|
||||
loadingPreview: "Preparing preview...",
|
||||
resourcesUnavailable:
|
||||
"Some preview resources could not be loaded. View the source or download the files for the complete content.",
|
||||
limited: (previewSize, totalSize) =>
|
||||
totalSize
|
||||
? `Showing the first ${previewSize} of ${totalSize}.`
|
||||
|
||||
@ -234,6 +234,8 @@ export interface Translations {
|
||||
};
|
||||
|
||||
artifactPreview: {
|
||||
loadingPreview: string;
|
||||
resourcesUnavailable: string;
|
||||
limited: (previewSize: string, totalSize?: string) => string;
|
||||
loadFullFile: string;
|
||||
loadingFullFile: string;
|
||||
|
||||
@ -284,6 +284,9 @@ export const zhCN: Translations = {
|
||||
},
|
||||
|
||||
artifactPreview: {
|
||||
loadingPreview: "正在准备预览...",
|
||||
resourcesUnavailable:
|
||||
"部分预览资源无法加载。可查看源代码或下载文件以获取完整内容。",
|
||||
limited: (previewSize, totalSize) =>
|
||||
totalSize
|
||||
? `当前显示 ${totalSize} 中的前 ${previewSize}。`
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
@ -89,6 +91,259 @@ function presentFilesMessages(path = PRESENTED_ARTIFACT_PATH) {
|
||||
}
|
||||
|
||||
test.describe("Artifact preview stability", () => {
|
||||
for (const [index, fixture] of [
|
||||
{
|
||||
name: "fragment",
|
||||
declaration: "",
|
||||
mode: "BackCompat",
|
||||
publicId: "",
|
||||
systemId: "",
|
||||
},
|
||||
{
|
||||
name: "HTML5",
|
||||
declaration: "<!doctype html>",
|
||||
mode: "CSS1Compat",
|
||||
publicId: "",
|
||||
systemId: "",
|
||||
},
|
||||
{
|
||||
name: "legacy quirks",
|
||||
declaration:
|
||||
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">',
|
||||
mode: "BackCompat",
|
||||
publicId: "-//W3C//DTD HTML 4.01 Transitional//EN",
|
||||
systemId: "",
|
||||
},
|
||||
{
|
||||
name: "XHTML transitional",
|
||||
declaration:
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
|
||||
mode: "CSS1Compat",
|
||||
publicId: "-//W3C//DTD XHTML 1.0 Transitional//EN",
|
||||
systemId: "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd",
|
||||
},
|
||||
].entries()) {
|
||||
test(`keeps a ${fixture.name} report open after resource 401 and rewrites SVG styles`, async ({
|
||||
page,
|
||||
context,
|
||||
baseURL,
|
||||
}) => {
|
||||
const threadId = `00000000-0000-0000-0000-00000000315${index}`;
|
||||
const filepath = "/mnt/user-data/outputs/partial-report.html";
|
||||
const content = `<!--before-->${fixture.declaration}<html><body><h1>Partial report</h1><img src="expired.png"><svg xmlns="http://www.w3.org/2000/svg"><style>.chart{filter:url(filters.svg#blur)}</style><rect class="chart" style="fill:url(paint.svg#paint)" width="40" height="40"/></svg></body></html><!--after-->`;
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: threadId,
|
||||
messages: presentFilesMessages(filepath),
|
||||
artifacts: [filepath],
|
||||
},
|
||||
],
|
||||
});
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "access_token",
|
||||
value: "preview-session",
|
||||
url: baseURL!,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
const resources: Array<{ filename: string; authenticated: boolean }> = [];
|
||||
await page.route(
|
||||
`**/api/threads/${threadId}/artifacts/mnt/user-data/outputs/*`,
|
||||
async (route) => {
|
||||
const filename = new URL(route.request().url()).pathname
|
||||
.split("/")
|
||||
.pop()!;
|
||||
const headers = await route.request().allHeaders();
|
||||
const authenticated =
|
||||
headers.cookie?.includes("access_token=preview-session") ?? false;
|
||||
if (filename !== "partial-report.html")
|
||||
resources.push({ filename, authenticated });
|
||||
if (!authenticated || filename === "expired.png")
|
||||
return route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: '{"detail":"Unauthorized"}',
|
||||
});
|
||||
if (filename === "partial-report.html")
|
||||
return route.fulfill({ contentType: "text/html", body: content });
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="blur"><feGaussianBlur stdDeviation="1"/></filter><linearGradient id="paint"><stop stop-color="blue"/></linearGradient></defs></svg>',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto(`/workspace/chats/${threadId}`);
|
||||
await page
|
||||
.getByText("partial-report.html", { exact: true })
|
||||
.first()
|
||||
.click();
|
||||
const panel = page.locator("#artifacts");
|
||||
await expect(panel.getByRole("status")).toContainText(
|
||||
"Some preview resources could not be loaded",
|
||||
);
|
||||
const iframe = panel.locator('iframe[title="Artifact preview"]');
|
||||
await expect(iframe).toHaveAttribute(
|
||||
"sandbox",
|
||||
"allow-scripts allow-forms",
|
||||
);
|
||||
const frame = iframe.contentFrame();
|
||||
await expect(frame.locator("h1")).toHaveText("Partial report");
|
||||
const result = await frame.locator("body").evaluate(() => ({
|
||||
mode: document.compatMode,
|
||||
doctype: document.doctype
|
||||
? {
|
||||
publicId: document.doctype.publicId,
|
||||
systemId: document.doctype.systemId,
|
||||
}
|
||||
: null,
|
||||
stylesheet: document.querySelector("svg style")?.textContent,
|
||||
inline: document.querySelector("svg rect")?.getAttribute("style"),
|
||||
comments: Array.from(document.childNodes)
|
||||
.filter((node) => node.nodeType === Node.COMMENT_NODE)
|
||||
.map((node) => node.textContent),
|
||||
}));
|
||||
expect(result.mode).toBe(fixture.mode);
|
||||
expect(result.doctype).toEqual(
|
||||
fixture.declaration
|
||||
? { publicId: fixture.publicId, systemId: fixture.systemId }
|
||||
: null,
|
||||
);
|
||||
expect(result.stylesheet).toContain("data:image/svg+xml;base64,");
|
||||
expect(result.inline).toContain("data:image/svg+xml;base64,");
|
||||
expect(result.stylesheet).toContain("#blur");
|
||||
expect(result.inline).toContain("#paint");
|
||||
expect(result.comments).toEqual(["before", "after"]);
|
||||
expect(resources).toHaveLength(3);
|
||||
expect(resources.every((resource) => resource.authenticated)).toBe(true);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${threadId}$`));
|
||||
});
|
||||
}
|
||||
|
||||
test("loads protected HTML sibling resources while retaining an opaque sandbox", async ({
|
||||
page,
|
||||
context,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
const threadId = "00000000-0000-0000-0000-000000003140";
|
||||
const filepath = "/mnt/user-data/outputs/authenticated-report.html";
|
||||
const stylesheet =
|
||||
'@import "nested.css"; h1{background-image:url(chart.png)}';
|
||||
const integrity = `sha256-${createHash("sha256").update(stylesheet).digest("base64")}`;
|
||||
const content = `<link rel="stylesheet" href="style.css" integrity="${integrity}"><script src="app.js"></script><h1>Authenticated report</h1><img id="chart" src="chart.png">`;
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: threadId,
|
||||
messages: presentFilesMessages(filepath),
|
||||
artifacts: [filepath],
|
||||
},
|
||||
],
|
||||
});
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "access_token",
|
||||
value: "preview-session",
|
||||
url: baseURL!,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
const resources: Array<{ filename: string; authenticated: boolean }> = [];
|
||||
await page.route(
|
||||
`**/api/threads/${threadId}/artifacts/mnt/user-data/outputs/*`,
|
||||
async (route) => {
|
||||
const filename = new URL(route.request().url()).pathname
|
||||
.split("/")
|
||||
.pop()!;
|
||||
const headers = await route.request().allHeaders();
|
||||
const authenticated =
|
||||
headers.cookie?.includes("access_token=preview-session") ?? false;
|
||||
if (filename !== "authenticated-report.html")
|
||||
resources.push({ filename, authenticated });
|
||||
if (!authenticated)
|
||||
return route.fulfill({ status: 401, body: "unauthorized" });
|
||||
const bodies: Record<
|
||||
string,
|
||||
{ contentType: string; body: string | Buffer }
|
||||
> = {
|
||||
"authenticated-report.html": {
|
||||
contentType: "text/html",
|
||||
body: content,
|
||||
},
|
||||
"style.css": {
|
||||
contentType: "text/css",
|
||||
body: stylesheet,
|
||||
},
|
||||
"nested.css": {
|
||||
contentType: "text/css",
|
||||
body: "h1{color:rgb(1,2,3)}",
|
||||
},
|
||||
"app.js": {
|
||||
contentType: "application/javascript",
|
||||
body: "window.addEventListener('DOMContentLoaded',()=>{document.body.dataset.scriptLoaded='true'})",
|
||||
},
|
||||
"chart.png": {
|
||||
contentType: "image/png",
|
||||
body: Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yA1kAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
},
|
||||
};
|
||||
const response = bodies[filename];
|
||||
if (!response) return route.fulfill({ status: 404 });
|
||||
await route.fulfill(response);
|
||||
},
|
||||
);
|
||||
await page.goto(`/workspace/chats/${threadId}`);
|
||||
await page
|
||||
.getByText("authenticated-report.html", { exact: true })
|
||||
.first()
|
||||
.click();
|
||||
const iframe = page.locator('#artifacts iframe[title="Artifact preview"]');
|
||||
await expect(iframe).toHaveAttribute(
|
||||
"sandbox",
|
||||
"allow-scripts allow-forms",
|
||||
);
|
||||
const frame = iframe.contentFrame();
|
||||
await expect(frame.locator("body")).toHaveAttribute(
|
||||
"data-script-loaded",
|
||||
"true",
|
||||
);
|
||||
await expect(frame.locator("h1")).toHaveCSS("color", "rgb(1, 2, 3)");
|
||||
await expect
|
||||
.poll(() =>
|
||||
frame
|
||||
.locator("#chart")
|
||||
.evaluate((image) => (image as HTMLImageElement).naturalWidth),
|
||||
)
|
||||
.toBe(1);
|
||||
expect(resources).toHaveLength(4);
|
||||
expect(resources.every((resource) => resource.authenticated)).toBe(true);
|
||||
expect(
|
||||
await frame.locator("body").evaluate(() => {
|
||||
try {
|
||||
return !!window.parent.document;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await frame.locator("body").evaluate(() => {
|
||||
try {
|
||||
return document.cookie;
|
||||
} catch {
|
||||
return "blocked";
|
||||
}
|
||||
}),
|
||||
).toBe("blocked");
|
||||
await iframe.screenshot({ path: testInfo.outputPath("html-preview.png") });
|
||||
});
|
||||
|
||||
for (const [index, filename] of ["constructor", "data.__proto__"].entries()) {
|
||||
test(`keeps ${filename} artifacts on the download fallback`, async ({
|
||||
page,
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ArtifactFilePreview } from "@/components/workspace/artifacts/artifact-file-preview";
|
||||
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
|
||||
rs.mock("@/core/artifacts/html-assets", () => ({
|
||||
prepareHtmlPreview: rs.fn(),
|
||||
}));
|
||||
rs.mock("@/core/streamdown/components", () => ({
|
||||
SafeStreamdown: () => null,
|
||||
toStreamdownComponents: () => ({}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.restoreAllMocks();
|
||||
rs.mocked(prepareHtmlPreview).mockReset();
|
||||
});
|
||||
|
||||
test("cancels obsolete preparation, hides previous content and releases document URLs", async () => {
|
||||
let resolveOld!: (
|
||||
result: Awaited<ReturnType<typeof prepareHtmlPreview>>,
|
||||
) => void;
|
||||
const prepare = rs.mocked(prepareHtmlPreview);
|
||||
prepare.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
);
|
||||
prepare.mockResolvedValue({
|
||||
content: "<h1>Current</h1>",
|
||||
failedResources: 0,
|
||||
});
|
||||
const create = rs
|
||||
.spyOn(URL, "createObjectURL")
|
||||
.mockReturnValue("blob:current");
|
||||
const revoke = rs
|
||||
.spyOn(URL, "revokeObjectURL")
|
||||
.mockImplementation(() => undefined);
|
||||
const component = (content: string) => (
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
|
||||
>
|
||||
<ArtifactFilePreview
|
||||
content={content}
|
||||
language="html"
|
||||
scrollKey={content}
|
||||
url={`/api/threads/${content}/artifacts/report.html`}
|
||||
/>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
const result = render(component("old"));
|
||||
const oldSignal = prepare.mock.calls[0]![2]!;
|
||||
result.rerender(component("current"));
|
||||
expect(oldSignal.aborted).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(result.container.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"blob:current",
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
resolveOld({ content: "<h1>Old</h1>", failedResources: 0 });
|
||||
});
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(result.container.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"blob:current",
|
||||
);
|
||||
prepare.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
result.rerender(component("pending"));
|
||||
expect(result.container.querySelector("iframe")?.hasAttribute("src")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(revoke).toHaveBeenCalledWith("blob:current");
|
||||
const pendingSignal = prepare.mock.calls[2]![2]!;
|
||||
result.unmount();
|
||||
expect(pendingSignal.aborted).toBe(true);
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||
|
||||
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
|
||||
|
||||
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
|
||||
|
||||
afterEach(() => {
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("keeps resource 401 responses local to the preview without navigating to login", async () => {
|
||||
const navigate = rs
|
||||
.spyOn(window.location, "href", "set")
|
||||
.mockImplementation(() => undefined);
|
||||
const request = rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response('{"detail":"Unauthorized"}', {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const url =
|
||||
"https://app.example/gateway/api/threads/chat/artifacts/mnt/user-data/outputs/report.html";
|
||||
const result = await prepareHtmlPreview(
|
||||
'<h1>Report</h1><img src="chart.png">',
|
||||
url,
|
||||
);
|
||||
expect(result.failedResources).toBe(1);
|
||||
expect(result.content).toContain("Report");
|
||||
expect(result.content).toContain('src="data:,"');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
url.replace("report.html", "chart.png"),
|
||||
expect.objectContaining({ credentials: "include", redirect: "error" }),
|
||||
);
|
||||
});
|
||||
395
frontend/tests/unit/core/artifacts/html-assets.dom.test.ts
Normal file
395
frontend/tests/unit/core/artifacts/html-assets.dom.test.ts
Normal file
@ -0,0 +1,395 @@
|
||||
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||
|
||||
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
|
||||
|
||||
const url =
|
||||
"https://app.example/api/threads/chat/artifacts/mnt/user-data/outputs/report.html";
|
||||
const request = rs.spyOn(globalThis, "fetch");
|
||||
|
||||
afterEach(() => {
|
||||
request.mockReset();
|
||||
});
|
||||
|
||||
test("preserves missing and legacy doctypes and comments outside the HTML element", async () => {
|
||||
for (const declaration of [
|
||||
"",
|
||||
"<!doctype html>",
|
||||
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">',
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
|
||||
]) {
|
||||
const content = `<!--before-->${declaration}<html><body><h1>Report</h1></body></html><!--after-->`;
|
||||
const result = await prepareHtmlPreview(content, url);
|
||||
const expected = new DOMParser().parseFromString(content, "text/html");
|
||||
const actual = new DOMParser().parseFromString(result.content, "text/html");
|
||||
expect(actual.doctype?.name).toBe(expected.doctype?.name);
|
||||
expect(actual.doctype?.publicId).toBe(expected.doctype?.publicId);
|
||||
expect(actual.doctype?.systemId).toBe(expected.doctype?.systemId);
|
||||
expect(result.content).toContain("<!--before-->");
|
||||
expect(result.content).toContain("<!--after-->");
|
||||
expect(actual.querySelector("base")?.getAttribute("href")).toBe(
|
||||
url.replace("report.html", ""),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rewrites URLs inside SVG style elements and inline style attributes", async () => {
|
||||
request.mockImplementation(async () => new Response("image"));
|
||||
// happy-dom's HTML parser drops SVG children; create the namespace nodes
|
||||
// directly. The real parser is also exercised by the browser regression.
|
||||
const fixture = document.implementation.createHTMLDocument("");
|
||||
const svg = fixture.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const style = fixture.createElementNS("http://www.w3.org/2000/svg", "style");
|
||||
style.textContent = ".chart{fill:url(chart.svg#paint)}";
|
||||
const rect = fixture.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("style", "filter:url(filters.svg#blur)");
|
||||
svg.append(style, rect);
|
||||
fixture.body.append(svg);
|
||||
const parse = rs
|
||||
.spyOn(DOMParser.prototype, "parseFromString")
|
||||
.mockReturnValueOnce(fixture);
|
||||
const result = await prepareHtmlPreview(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><style>.chart{fill:url(chart.svg#paint)}</style><rect class="chart" style="filter:url(filters.svg#blur)"/></svg>',
|
||||
url,
|
||||
);
|
||||
parse.mockRestore();
|
||||
expect(
|
||||
request.mock.calls.map(([input]) =>
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
),
|
||||
).toEqual([
|
||||
url.replace("report.html", "chart.svg"),
|
||||
url.replace("report.html", "filters.svg"),
|
||||
]);
|
||||
expect(style.textContent).toContain("data:");
|
||||
expect(rect.getAttribute("style")).toContain("data:");
|
||||
expect(result.content).toContain("#paint");
|
||||
expect(result.content).toContain("#blur");
|
||||
});
|
||||
|
||||
test("authenticates images, scripts and nested CSS assets in the parent", async () => {
|
||||
request.mockImplementation(async (input) => {
|
||||
const pathname = new URL(
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
).pathname;
|
||||
if (pathname.endsWith("main.css"))
|
||||
return new Response(
|
||||
'@import "nested.css" screen; .hero{background:url("chart%2520.png")}',
|
||||
);
|
||||
if (pathname.endsWith("nested.css"))
|
||||
return new Response(".icon{background:url(../uploads/icon.png)}");
|
||||
return new Response(
|
||||
pathname.endsWith("app.js") ? "window.loaded=true" : "image",
|
||||
);
|
||||
});
|
||||
const result = await prepareHtmlPreview(
|
||||
'<link rel="stylesheet" href="main.css"><script src="app.js"></script><img src="chart%2520.png">',
|
||||
url,
|
||||
);
|
||||
expect(result.failedResources).toBe(0);
|
||||
expect(
|
||||
request.mock.calls.map(([input]) =>
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
),
|
||||
).toEqual([
|
||||
url.replace("report.html", "main.css"),
|
||||
url.replace("report.html", "nested.css"),
|
||||
url.replace("outputs/report.html", "uploads/icon.png"),
|
||||
url.replace("report.html", "chart%2520.png"),
|
||||
url.replace("report.html", "app.js"),
|
||||
]);
|
||||
expect(result.content).not.toContain('src="chart%2520.png"');
|
||||
const document = new DOMParser().parseFromString(result.content, "text/html");
|
||||
const css = atob(
|
||||
document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
|
||||
);
|
||||
expect(css).toContain("data:text/css");
|
||||
expect(
|
||||
request.mock.calls.every(([, init]) => init?.redirect === "error"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("never fetches another thread, external origin, non-artifact route or encoded separators", async () => {
|
||||
const result = await prepareHtmlPreview(
|
||||
`<img src="https://evil.example/image"><img src="/api/threads/other/artifacts/mnt/a.png"><img src="/api/memory"><img src="..%2fsecret.png"><img src="%5csecret.png"><img src="data:image/png;base64,AA">`,
|
||||
url,
|
||||
);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain("https://evil.example/image");
|
||||
});
|
||||
|
||||
test("bounds cycles and failed resources without losing the HTML", async () => {
|
||||
request.mockImplementation(async (input) => {
|
||||
if (
|
||||
(typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url
|
||||
).endsWith("a.css")
|
||||
)
|
||||
return new Response('@import "b.css";');
|
||||
if (
|
||||
(typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url
|
||||
).endsWith("b.css")
|
||||
)
|
||||
return new Response('@import "a.css";');
|
||||
return new Response("missing", { status: 404 });
|
||||
});
|
||||
const result = await prepareHtmlPreview(
|
||||
'<h1>Report</h1><link rel="stylesheet" href="a.css"><img src="missing.png">',
|
||||
url,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(result.failedResources).toBe(2);
|
||||
expect(result.content).toContain("Report");
|
||||
expect(result.content).not.toContain('src="missing.png"');
|
||||
});
|
||||
|
||||
test("rewrites inline CSS and ignores commented URL text", async () => {
|
||||
request.mockResolvedValue(new Response("image"));
|
||||
const result = await prepareHtmlPreview(
|
||||
'<style>/*url(no.png)*/ .x{background:url("chart.png")}</style><div style="background:url(chart.png)"></div>',
|
||||
url,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(result.content).toContain("/*url(no.png)*/");
|
||||
expect(result.content).toContain("data:");
|
||||
});
|
||||
|
||||
test("leaves public demos and previews without a resource URL on their existing path", async () => {
|
||||
const content = '<img src="chart.png">';
|
||||
for (const value of [
|
||||
undefined,
|
||||
"https://app.example/demo/threads/chat/report.html",
|
||||
]) {
|
||||
const result = await prepareHtmlPreview(content, value);
|
||||
expect(result.content).toContain(content);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves external bases and escaped external CSS references", async () => {
|
||||
const result = await prepareHtmlPreview(
|
||||
'<base href="https://cdn.example/"><img src="image.png"><style>.x{background:url(https://cdn.example/a\\)b.png)}</style>',
|
||||
url,
|
||||
);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain('src="image.png"');
|
||||
expect(result.content).toContain("a\\)b.png");
|
||||
});
|
||||
|
||||
test("supports responsive pictures, SVG fragments and CSS escaped filenames", async () => {
|
||||
request.mockImplementation(async () => new Response("image"));
|
||||
const result = await prepareHtmlPreview(
|
||||
'<picture><source srcset="one.png 1x, two.png 2x"><img srcset="data:image/png;base64,AA 1x, one.png 2x" src="icons.svg#chart"></picture><style>.x{background:url("chart\\20 image.png")}</style>',
|
||||
url,
|
||||
);
|
||||
expect(
|
||||
request.mock.calls.map(([input]) =>
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
),
|
||||
).toEqual([
|
||||
url.replace("report.html", "one.png"),
|
||||
url.replace("report.html", "two.png"),
|
||||
url.replace("report.html", "icons.svg"),
|
||||
url.replace("report.html", "chart%20image.png"),
|
||||
]);
|
||||
expect(result.content).toContain("data:image/png;base64,AA 1x");
|
||||
expect(result.content).toContain("#chart");
|
||||
expect(result.failedResources).toBe(0);
|
||||
});
|
||||
|
||||
test("caps distinct resources and CSS import depth", async () => {
|
||||
request.mockImplementation(async () => new Response("image"));
|
||||
const result = await prepareHtmlPreview(
|
||||
Array.from({ length: 40 }, (_, i) => `<img src="${i}.png">`).join(""),
|
||||
url,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(32);
|
||||
expect(result.failedResources).toBe(8);
|
||||
request.mockClear();
|
||||
request.mockImplementation(async (input) => {
|
||||
const name = new URL(
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
).pathname
|
||||
.split("/")
|
||||
.pop()!;
|
||||
return new Response(`@import "${Number.parseInt(name) + 1}.css";`);
|
||||
});
|
||||
const nested = await prepareHtmlPreview(
|
||||
'<link rel="stylesheet" href="0.css">',
|
||||
url,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(9);
|
||||
expect(nested.failedResources).toBe(1);
|
||||
});
|
||||
|
||||
test("caps streamed resource bytes and the aggregate budget", async () => {
|
||||
request.mockImplementation(
|
||||
async () => new Response(new Uint8Array(4 * 1024 * 1024 + 1)),
|
||||
);
|
||||
const oversized = await prepareHtmlPreview('<img src="huge.png">', url);
|
||||
expect(oversized.failedResources).toBe(1);
|
||||
expect(oversized.content).toContain('src="data:,"');
|
||||
request.mockClear();
|
||||
request.mockImplementation(
|
||||
async () => new Response(new Uint8Array(4 * 1024 * 1024)),
|
||||
);
|
||||
const aggregate = await prepareHtmlPreview(
|
||||
Array.from({ length: 5 }, (_, i) => `<img src="${i}.png">`).join(""),
|
||||
url,
|
||||
);
|
||||
expect(aggregate.failedResources).toBe(1);
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
test("bounds repeated data URLs and preserves out-of-route CSS resolution", async () => {
|
||||
request.mockImplementation(
|
||||
async () => new Response(new Uint8Array(4 * 1024 * 1024)),
|
||||
);
|
||||
const repeated = await prepareHtmlPreview(
|
||||
Array.from({ length: 8 }, () => '<img src="chart.png">').join(""),
|
||||
url,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(repeated.failedResources).toBeGreaterThan(0);
|
||||
expect(repeated.content.length).toBeLessThan(24 * 1024 * 1024);
|
||||
request.mockClear();
|
||||
request.mockResolvedValue(
|
||||
new Response(".x{background:url(/public/chart.png)}"),
|
||||
);
|
||||
const css = await prepareHtmlPreview(
|
||||
'<link rel="stylesheet" href="style.css">',
|
||||
url,
|
||||
);
|
||||
const document = new DOMParser().parseFromString(css.content, "text/html");
|
||||
const stylesheet = atob(
|
||||
document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
|
||||
);
|
||||
expect(stylesheet).toContain("https://app.example/public/chart.png");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("quotes absolute CSS URLs that replace escaped unquoted references", async () => {
|
||||
request.mockResolvedValue(
|
||||
new Response(".chart{background:url(//cdn.example/chart\\(1\\).png)}"),
|
||||
);
|
||||
const result = await prepareHtmlPreview(
|
||||
'<link rel="stylesheet" href="style.css">',
|
||||
url,
|
||||
);
|
||||
const document = new DOMParser().parseFromString(result.content, "text/html");
|
||||
const stylesheet = atob(
|
||||
document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
|
||||
);
|
||||
expect(stylesheet).toContain('url("https://cdn.example/chart(1).png")');
|
||||
expect(result.failedResources).toBe(0);
|
||||
});
|
||||
|
||||
test("aborts late responses before exposing prepared HTML", async () => {
|
||||
const controller = new AbortController();
|
||||
request.mockImplementation(async (input) => {
|
||||
if (
|
||||
(typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url
|
||||
).endsWith("late.png")
|
||||
)
|
||||
controller.abort();
|
||||
return new Response("image");
|
||||
});
|
||||
await expect(
|
||||
prepareHtmlPreview(
|
||||
'<img src="first.png"><img src="late.png">',
|
||||
url,
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("validates original integrity before rewriting resources", async () => {
|
||||
request.mockImplementation(async () => new Response("body"));
|
||||
const result = await prepareHtmlPreview(
|
||||
'<link rel="stylesheet" href="style.css" integrity="sha256-original"><script src="app.js" integrity="sha256-script"></script><link rel="stylesheet" href="https://cdn.example/style.css" integrity="sha256-external">',
|
||||
url,
|
||||
);
|
||||
expect(request.mock.calls.map(([, init]) => init?.integrity)).toEqual([
|
||||
"sha256-original",
|
||||
"sha256-script",
|
||||
]);
|
||||
expect(result.content).not.toContain("sha256-original");
|
||||
expect(result.content).not.toContain("sha256-script");
|
||||
expect(result.content).toContain("sha256-external");
|
||||
});
|
||||
|
||||
test("never reuses an unverified resource for a distinct integrity constraint", async () => {
|
||||
request.mockImplementation(async (_input, init) => {
|
||||
if (init?.integrity === "sha256-wrong")
|
||||
throw new TypeError("Integrity mismatch");
|
||||
return new Response("image");
|
||||
});
|
||||
const result = await prepareHtmlPreview(
|
||||
'<img src="chart.png"><img src="chart.png" integrity="sha256-valid"><img src="chart.png" integrity="sha256-wrong">',
|
||||
url,
|
||||
);
|
||||
expect(request.mock.calls.map(([, init]) => init?.integrity)).toEqual([
|
||||
undefined,
|
||||
"sha256-valid",
|
||||
"sha256-wrong",
|
||||
]);
|
||||
expect(result.failedResources).toBe(1);
|
||||
expect(result.content).toContain('src="data:,"');
|
||||
});
|
||||
|
||||
test("returns a partial preview when the preparation deadline expires", async () => {
|
||||
rs.useFakeTimers();
|
||||
try {
|
||||
request.mockImplementation(
|
||||
(_input, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("Aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const pending = prepareHtmlPreview(
|
||||
'<h1>Report</h1><img src="slow.png"><img src="next.png">',
|
||||
url,
|
||||
);
|
||||
await rs.advanceTimersByTimeAsync(10_000);
|
||||
const result = await pending;
|
||||
expect(result.failedResources).toBe(2);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(result.content).toContain("Report");
|
||||
} finally {
|
||||
rs.useRealTimers();
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user