preview: inline uploaded assets as data: URLs so they render in the sandboxed Preview

Preview (and version-history Preview) renders the page in an
<iframe sandbox srcdoc> without allow-same-origin, i.e. an opaque origin.
Every site-builder asset response carries Cross-Origin-Resource-Policy:
same-site (whp libs/SiteBuilderAssetServe.php), so each upload requested
from that frame is cross-site and blocked (ERR_BLOCKED_BY_RESPONSE.NotSameSite,
naturalWidth 0). The canvas was fine; Preview images were broken for everyone.

CORP and the sandbox stay as they are. Before the HTML is handed to the
sandboxed frame, the editor (panel origin, same-origin to the proxy) fetches
this site's proxy assets and swaps each URL for a data: URL in the HTML text
(utils/preview-assets.ts): img src/srcset, poster, video src, <link> icon,
inline-style and <style> url(), &quot;/&amp; forms, both proxy URL forms.
<a href>, <meta content>, other sites, other origins are left alone. Per-asset
(8 MiB), total (40 MiB), count (200) and timeout (15 s) caps; any per-asset
failure keeps the original URL. Save/Publish/export are untouched.

blob: was measured and rejected: in Chromium and Firefox a blob: URL made by
the editor window or by the Preview tab does not load inside the null-origin
frame; data: does.

Tests: vitest for the collector/rewriter; new real-browser suite
test:browser:preview-assets (real HTTP stub with the backend's CORP header,
Preview opened by a click through the real openers) reproduces the block with
the old opener and passes with the new one in Chromium and Firefox, measures
blob vs data, and checks the real backend publish holds only assets/<file>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-02 20:29:36 -08:00
1 parent c6809293c4
commit 8ff0e01c45
7 files changed
+596 -4

No files matched your search

+1
View File
@@ -23,6 +23,7 @@
"test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs",
"test:browser:sitesmith-editor-assets": "node scripts/e2e-sitesmith-editor-assets.mjs",
"test:browser:sitesmith-cleaned": "node scripts/e2e-sitesmith-cleaned.mjs",
"test:browser:preview-assets": "node scripts/e2e-preview-assets.mjs",
"test:unit:watch": "vitest"
},
"dependencies": {
+47
View File
@@ -0,0 +1,47 @@
// Bundled (esbuild, node) by scripts/e2e-preview-assets.mjs: a version-history snapshot whose header, body and
// design carry uploads exactly as the AssetPicker stores them (the same-origin panel proxy URL), turned into the
// Preview document by the REAL buildSnapshotPreviewHtml (the same export + absolute-URL step as TopBar's Preview),
// plus the published body for the backend publish check.
import { editorProxyUrl } from '../src/sitesmith/editor-asset-urls';
import { buildSnapshotPreviewHtml, type Snapshot } from '../src/utils/history-restore';
import { exportBodyHtml } from '../src/utils/html-export';
import { flattenTreeForCraft } from '../src/utils/craft-tree';
import { inlinePreviewAssets, collectPreviewAssetUrls } from '../src/utils/preview-assets';
export const SITE_ID = 7;
export const FILES = ['1700000000_aa11bb22_hero.png', '1700000001_bb22cc33_logo+v2.png', 'my photo.png', 'café.png', 'poster.png', 'fav.png'];
export const OTHER_SITE_FILE = 'other-site.png';
const U = (i: number) => editorProxyUrl(SITE_ID, FILES[i]);
const n = (type: string, props: Record<string, unknown>, nodes?: any[]) => ({ type: { resolvedName: type }, props, ...(nodes ? { nodes } : {}) });
const state = (tree: any) => JSON.stringify(flattenTreeForCraft(tree).nodes);
const header = state(n('Container', { node_id: 'ROOT', tag: 'div' }, [
n('Navbar', { node_id: 'nav', logoType: 'image', logoImage: U(1), logoUrl: '/', links: [{ text: 'Home', href: '/' }] }),
]));
const page = state(n('Container', { node_id: 'ROOT', tag: 'div' }, [
n('BackgroundSection', { node_id: 'bg', bgImage: U(0), overlayOpacity: 0.2 }, [n('Heading', { node_id: 'h', text: 'Hello', level: 'h1' })]),
n('ImageBlock', { node_id: 'i1', src: U(0), alt: 'plain' }),
n('ImageBlock', { node_id: 'i2', src: U(1), alt: 'plus' }),
n('ImageBlock', { node_id: 'i3', src: U(2), alt: 'space' }),
n('ImageBlock', { node_id: 'i4', src: U(3), alt: 'unicode' }),
n('Container', { node_id: 'c', tag: 'div', style: { minHeight: '60px', backgroundImage: `url(${U(0)})` } }),
n('HtmlBlock', { node_id: 'html', code:
`<img id="rich" src="${U(0)}" alt="rich"><img id="srcset" alt="srcset" src="${U(2)}" srcset="${U(0)} 1x, ${U(3)} 2x">`
+ `<video id="vid" poster="${U(4)}" width="40" height="30"></video>`
+ `<a id="lnk" href="${U(0)}">full size</a>`
+ `<img id="other" alt="other" src="${editorProxyUrl(8, OTHER_SITE_FILE)}">` }),
n('ImageBlock', { node_id: 'i5', src: editorProxyUrl(SITE_ID, 'does-not-exist.png'), alt: 'missing' }),
]));
export const SNAPSHOT: Snapshot = {
pages_craft_state: [{ id: 'p1', name: 'Home', slug: 'index', craftState: page }],
header_craft_state: header,
footer_craft_state: null,
design: { favicon: U(5) },
} as unknown as Snapshot;
/** The Preview document as origin/main hands it to the sandboxed tab (proxy URLs made absolute on `origin`). */
export function previewHtml(origin: string): string { return buildSnapshotPreviewHtml(SNAPSHOT, 0, origin); }
/** The page body Save/Publish sends (no Preview step involved). */
export function publishBody(): string { return exportBodyHtml(header).html + exportBodyHtml(page).html; }
export { inlinePreviewAssets, collectPreviewAssetUrls };
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* Real-browser check (Chromium AND Firefox, headless) that uploaded images render in PREVIEW, the way the editor
* really opens it: a click opens a panel-origin tab (window.open('')), which holds an
* <iframe sandbox="allow-scripts allow-popups allow-forms" srcdoc> -- an OPAQUE (null) origin
* (utils/sandboxed-preview.ts). The asset stub is a real HTTP server sending the same headers as
* whp libs/SiteBuilderAssetServe.php, above all `Cross-Origin-Resource-Policy: same-site`.
* 1. control (origin/main behaviour, openSandboxedPreview on the exported HTML): every upload image is
* blocked by CORP (naturalWidth 0) -- the canary bug (ERR_BLOCKED_BY_RESPONSE.NotSameSite);
* 2. fixed (openPreviewWithInlinedAssets): the editor fetches this site's proxy assets and hands them over as
* data: URLs; every upload image, srcset, poster, favicon and background loads; a missing file, another
* site's file and an <a href> link keep their original URL; the frame is still null-origin;
* 3. measured: a blob: URL made by the editor window or by the Preview tab does NOT load in that frame
* (why data: is used);
* 4. publish: the same page through the REAL backend sb_rewrite_asset_urls + generateCompiledHTML holds
* `assets/<file>` only -- no data: URL, no proxy URL (Preview-only transformation).
* Nothing leaves the machine: the server listens on 127.0.0.1 and every other request is aborted.
* npm run test:browser:preview-assets Env: WHP_BACKEND_DIR (a whp worktree root), CHROMIUM_PATH / FIREFOX_PATH
*/
import { chromium, firefox } from '@playwright/test';
import { build as esbuild } from 'esbuild';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const BACKEND_DIR = process.env.WHP_BACKEND_DIR || '/workspace/whp';
const define = { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' };
// node side: the Preview document from the real exporter
const nodeOut = await esbuild({
entryPoints: [path.join(root, 'scripts/e2e-preview-assets-entry.ts')],
bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error', define,
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
});
const tmp = path.join(os.tmpdir(), `wsb-preview-assets-${process.pid}.mjs`);
fs.writeFileSync(tmp, nodeOut.outputFiles[0].text);
const { JSDOM } = await import('jsdom');
const dom = new JSDOM('<!doctype html><html><body></body></html>');
globalThis.window = dom.window; globalThis.document = dom.window.document;
const ent = await import(tmp);
fs.rmSync(tmp, { force: true });
// browser side: the real Preview openers
const browserJs = (await esbuild({
stdin: {
contents: "import { openSandboxedPreview } from './src/utils/sandboxed-preview';\n"
+ "import { openPreviewWithInlinedAssets } from './src/utils/preview-assets';\n"
+ 'window.WSB = { openSandboxedPreview, openPreviewWithInlinedAssets };',
resolveDir: root, loader: 'ts',
},
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error', define,
})).outputFiles[0].text;
let pass = 0; let fail = 0; let skip = 0;
const ok = (c, m, d = '') => { if (c) { pass++; console.log('PASS ', m); } else { fail++; console.log('FAIL ', m, d); } };
// An 8x8 PNG: a 1x1 image picked as a srcset 2x candidate has a density-corrected naturalWidth of 0.5 -> 0.
const PNG = (() => {
const crcT = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]); };
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(8, 0); ihdr.writeUInt32BE(8, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.concat(Array.from({ length: 8 }, () => Buffer.from([0, ...Array(24).fill(200)])));
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
})();
const ASSET_HEADERS = { // libs/SiteBuilderAssetServe.php sb_serve_asset()
'Content-Type': 'image/png', 'Content-Length': String(PNG.length), 'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-site', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
'Cache-Control': 'public, max-age=86400',
};
const srv = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/site-builder/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
return res.end('<!doctype html><title>editor</title><script src="/wsb.js"></script><button id="old">old</button><button id="new">new</button>'
+ '<script>const go=(m)=>{const win=window.open("","_blank");setTimeout(()=>{m==="old"?WSB.openSandboxedPreview(window.__html,{title:"Preview",win})'
+ ':WSB.openPreviewWithInlinedAssets(window.__html,{title:"Preview",win,origin:location.origin,siteId:7})},50)};'
+ 'document.getElementById("old").onclick=()=>go("old");document.getElementById("new").onclick=()=>go("new");</script>');
}
if (u.pathname === '/wsb.js') { res.writeHead(200, { 'Content-Type': 'text/javascript' }); return res.end(browserJs); }
if (u.pathname === '/api/assets-proxy/') {
const site = u.searchParams.get('site_id'); const f = u.searchParams.get('filename');
if ((site === String(ent.SITE_ID) && ent.FILES.includes(f)) || (site === '8' && f === ent.OTHER_SITE_FILE)) { res.writeHead(200, ASSET_HEADERS); return res.end(PNG); }
}
res.writeHead(404); res.end();
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const ORIGIN = `http://127.0.0.1:${srv.address().port}`;
const html = ent.previewHtml(ORIGIN);
// ------------------------------------------------ static: the inliner only swaps this site's proxy URLs
ok(html.includes(`src="${ORIGIN}/api/assets-proxy/?site_id=7&amp;filename=`), 'control document carries absolute same-origin proxy URLs (as origin/main builds it)');
const hits = ent.collectPreviewAssetUrls(html, { origin: ORIGIN, siteId: ent.SITE_ID });
const names = [...new Set(hits.map((h) => new URL(h.key).searchParams.get('filename')))];
ok(ent.FILES.every((f) => names.includes(f)), 'collector finds every upload: img, srcset, poster, logo, favicon, backgrounds', JSON.stringify(names));
ok(!names.includes(ent.OTHER_SITE_FILE), "collector leaves another site's proxy URL alone");
// ------------------------------------------------ publish through the real backend (Preview-only transformation)
if (fs.existsSync(path.join(BACKEND_DIR, 'web-files/api/site-builder.php'))) {
const pub = JSON.parse(execFileSync('php', [path.join(root, 'scripts/e2e-sitesmith-editor-assets-backend.php'), BACKEND_DIR],
{ input: JSON.stringify({ html: ent.publishBody(), css: '' }), maxBuffer: 1 << 26 }).toString());
ok(!/data:image|assets-proxy|serve_asset/.test(pub.body + pub.doc), 'published body + document hold no data: URL and no proxy URL');
ok(['assets/1700000000_aa11bb22_hero.png', 'assets/1700000001_bb22cc33_logo%2Bv2.png', 'assets/my%20photo.png', 'assets/caf%C3%A9.png', 'assets/poster.png'].every((r) => pub.body.includes(r)),
'published body references the uploads as assets/<file>', pub.body.match(/assets\/[^"')\s]*/g)?.join(' '));
} else { skip++; console.log(`SKIP published-output checks: no backend at WHP_BACKEND_DIR=${BACKEND_DIR}`); }
/** Open Preview with the given opener via a real click and read what the sandboxed frame shows. */
async function preview(browser, mode) {
const ctx = await browser.newContext();
await ctx.route('**/*', (route) => (new URL(route.request().url()).origin === ORIGIN || route.request().url().startsWith('data:') ? route.continue() : route.abort()));
const page = await ctx.newPage();
await page.goto(ORIGIN + '/site-builder/');
await page.evaluate((h) => { window.__html = h; }, html);
const [popup] = await Promise.all([page.waitForEvent('popup'), page.click('#' + mode)]);
const fh = await popup.waitForSelector('iframe', { timeout: 15000 });
const frame = await fh.contentFrame();
await frame.waitForSelector('img#rich', { timeout: 15000 });
await frame.waitForFunction(() => [...document.images].every((i) => i.complete), null, { timeout: 15000 }).catch(() => {});
await popup.waitForTimeout(300);
const r = await frame.evaluate(async () => {
const imgs = Object.fromEntries([...document.images].map((i) => [i.id || i.alt, { w: i.naturalWidth, src: i.currentSrc || i.src }]));
const bgs = [...document.querySelectorAll('body *')].map((e) => getComputedStyle(e).backgroundImage).filter((b) => b && b !== 'none' && b.includes('url('));
const bgLoads = await Promise.all(bgs.map((b) => new Promise((res) => { const m = /url\("?(.*?)"?\)$/.exec(b); const i = new Image(); i.onload = () => res(i.naturalWidth > 0); i.onerror = () => res(false); i.src = m ? m[1] : ''; })));
return {
origin: String(self.origin), imgs, bgs: bgs.map((b) => b.slice(0, 60)), bgLoads,
poster: document.getElementById('vid')?.getAttribute('poster')?.slice(0, 30),
link: document.getElementById('lnk')?.getAttribute('href'),
icon: document.querySelector('link[rel="icon"]')?.getAttribute('href')?.slice(0, 30),
};
});
const sandbox = await fh.getAttribute('sandbox');
await ctx.close();
return { ...r, sandbox };
}
/** Blob URLs (editor window / Preview tab) inside the same kind of null-origin srcdoc frame: measured, not assumed. */
async function blobProbe(browser) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(ORIGIN + '/site-builder/');
const res = await page.evaluate(async () => {
const win = window.open('', '_blank');
const bytes = await (await fetch('/api/assets-proxy/?site_id=7&filename=fav.png')).arrayBuffer();
const editorBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
const tabBlob = win.URL.createObjectURL(new win.Blob([bytes], { type: 'image/png' }));
const data = 'data:image/png;base64,' + btoa(String.fromCharCode(...new Uint8Array(bytes)));
return await new Promise((resolve) => {
win.addEventListener('message', (e) => resolve(e.data));
const f = win.document.createElement('iframe');
f.setAttribute('sandbox', 'allow-scripts allow-popups allow-forms');
f.srcdoc = `<img id="editorBlob" src="${editorBlob}"><img id="tabBlob" src="${tabBlob}"><img id="data" src="${data}">`
+ '<script>onload=()=>parent.postMessage(Object.fromEntries([...document.images].map(i=>[i.id,i.naturalWidth])),"*")<\/script>';
win.document.body.appendChild(f);
setTimeout(() => resolve(null), 8000);
});
});
await ctx.close();
return res;
}
const UPLOADS = ['plain', 'plus', 'space', 'unicode', 'rich', 'srcset', 'Logo'];
for (const [name, launcher, exe] of [['chromium', chromium, process.env.CHROMIUM_PATH], ['firefox', firefox, process.env.FIREFOX_PATH]]) {
let browser;
try { browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); }
catch (e) { skip++; console.log(`SKIP [${name}] browser not available: ${String(e.message).split('\n')[0]}`); continue; }
const up = (r) => Object.entries(r.imgs).filter(([k]) => UPLOADS.includes(k) || /logo/i.test(k));
const control = await preview(browser, 'old');
ok(control.origin === 'null' && control.sandbox === 'allow-scripts allow-popups allow-forms', `[${name}] control: Preview frame is the sandboxed null-origin srcdoc`, `${control.origin} ${control.sandbox}`);
ok(up(control).length >= 6 && up(control).every(([, v]) => v.w === 0), `[${name}] control (origin/main): every upload image is BLOCKED by CORP same-site (naturalWidth 0)`, JSON.stringify(control.imgs));
ok(control.bgLoads.length >= 2 && control.bgLoads.every((b) => !b), `[${name}] control: the proxy backgrounds do not load either`, JSON.stringify(control.bgs));
const fixed = await preview(browser, 'new');
ok(fixed.origin === 'null' && fixed.sandbox === 'allow-scripts allow-popups allow-forms', `[${name}] fixed: the frame is STILL the sandboxed null-origin srcdoc (sandbox unchanged)`, `${fixed.origin} ${fixed.sandbox}`);
ok(up(fixed).length >= 6 && up(fixed).every(([, v]) => v.w > 0 && v.src.startsWith('data:image/png;base64,')), `[${name}] fixed: every upload image (plain, +, space, unicode, HtmlBlock, srcset, logo) loads from a data: URL`, JSON.stringify(Object.fromEntries(up(fixed).map(([k, v]) => [k, [v.w, v.src.slice(0, 22)]]))));
ok(fixed.bgs.length >= 2 && fixed.bgs.every((b) => b.startsWith('url("data:image/png')) && fixed.bgLoads.every(Boolean), `[${name}] fixed: BackgroundSection and CSS url() backgrounds are data: and load`, JSON.stringify(fixed.bgs));
ok(fixed.poster?.startsWith('data:image/png') && fixed.icon?.startsWith('data:image/png'), `[${name}] fixed: video poster and favicon are inlined`, `${fixed.poster} ${fixed.icon}`);
ok(fixed.imgs.missing?.w === 0 && fixed.imgs.missing.src.includes('/api/assets-proxy/?site_id=7&filename=does-not-exist.png'), `[${name}] fixed: a missing file keeps its proxy URL (no worse than before)`, JSON.stringify(fixed.imgs.missing));
ok(fixed.imgs.other?.src.includes('site_id=8') && fixed.imgs.other.w === 0, `[${name}] fixed: another site's proxy URL is not fetched or inlined`, JSON.stringify(fixed.imgs.other));
ok(fixed.link?.startsWith(ORIGIN + '/api/assets-proxy/') || fixed.link?.startsWith('/api/assets-proxy/'), `[${name}] fixed: an <a href> to an upload stays a link`, fixed.link);
const probe = await blobProbe(browser);
ok(probe && probe.editorBlob === 0 && probe.tabBlob === 0 && probe.data > 0, `[${name}] measured: blob: from the editor window and from the Preview tab do NOT load in the null-origin frame; data: does`, JSON.stringify(probe));
await browser.close();
}
srv.close();
console.log(`\n${pass} passed, ${fail} failed, ${skip} skipped`);
process.exit(fail ? 1 : 0);
+8 -2
View File
@@ -10,7 +10,7 @@ import { useNotices } from '../../state/NoticeContext';
import { useRegisterCommands } from '../../editor/commands';
import { useHistoryApi, type HistoryItem } from '../../hooks/useHistoryApi';
import { repairOrphanNodes } from '../../utils/orphan-repair';
import { openSandboxedPreview } from '../../utils/sandboxed-preview';
import { openPreviewWithInlinedAssets } from '../../utils/preview-assets';
import {
buildSnapshotPreviewHtml,
planPageRestore,
@@ -159,7 +159,13 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
const html = buildSnapshotPreviewHtml(snapshot, pageIndex, window.location.origin);
// The snapshot's head code is customer-controlled: sandboxed srcdoc iframe only.
const pageName = snapshotToPages(snapshot)[pageIndex]?.name;
openSandboxedPreview(html, { title: pageName ? `Version preview - ${pageName}` : 'Version preview', win });
// Uploads are inlined as data: URLs first: the sandboxed frame's opaque origin cannot load the
// CORP same-site proxy (utils/preview-assets.ts). Its own failures fall back to the plain HTML.
const siteId = (window as { WHP_CONFIG?: { siteId?: number } }).WHP_CONFIG?.siteId;
void openPreviewWithInlinedAssets(html, {
title: pageName ? `Version preview - ${pageName}` : 'Version preview', win,
origin: window.location.origin, siteId,
}).catch((e) => { console.error('History preview failed:', e); win?.close(); });
} catch (e) {
console.error('History preview failed:', e);
win?.close();
+5 -2
View File
@@ -279,7 +279,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
const win = window.open('', '_blank');
try {
const serialized = query.serialize();
Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportComposedPreviewHtml, exportBodyHtml }, { openSandboxedPreview }]) => {
Promise.all([import('../../utils/html-export'), import('../../utils/preview-assets')]).then(([{ exportComposedPreviewHtml, exportBodyHtml }, { openPreviewWithInlinedAssets }]) => {
// Get header HTML
let headerHtml = '';
try {
@@ -338,7 +338,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
// Customer head code is in `html`: render it ONLY in a sandboxed srcdoc
// iframe (no allow-same-origin), never as a panel-origin document.
openSandboxedPreview(html, { title, win });
// That frame has an opaque origin, so the proxy's CORP: same-site blocks
// the uploads it requests; the editor (same origin) fetches them first and
// hands them over as data: URLs (Preview only -- see utils/preview-assets.ts).
return openPreviewWithInlinedAssets(html, { title, win, origin, siteId: whpConfig?.siteId });
}).catch((e) => {
console.error('Preview failed:', e);
win?.close();
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from 'vitest';
import { collectPreviewAssetUrls, inlinePreviewAssets } from './preview-assets';
const O = 'https://panel.example:8443';
const P = (f: string, site = 7) => `/api/assets-proxy/?site_id=${site}&filename=${f}`;
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
const PNG_DATA = 'data:image/png;base64,' + btoa(String.fromCharCode(...PNG));
function fakeFetch(map: Record<string, { status?: number; type?: string; body?: Uint8Array; len?: string } | 'throw'>) {
return vi.fn(async (url: string) => {
const r = map[url];
if (!r) return new Response('', { status: 404 });
if (r === 'throw') throw new TypeError('network');
const headers: Record<string, string> = { 'content-type': r.type ?? 'image/png' };
if (r.len) headers['content-length'] = r.len;
return new Response((r.body ?? PNG) as unknown as BodyInit, { status: r.status ?? 200, headers });
}) as unknown as typeof fetch & ReturnType<typeof vi.fn>;
}
describe('collectPreviewAssetUrls', () => {
it('finds every resource context in document order: img src, srcset, poster, video src, link icon, inline style, <style>, &quot; and &amp; forms', () => {
const html = [
`<link rel="icon" href="${P('fav.png')}">`,
`<style>.h{background:url("${P('css.png')}")} .k{background:url(${P('bare.png')})}</style>`,
`<img src="${O}${P('abs.png')}" srcset="${P('a1.png')} 1x, ${P('a2.png')} 2x">`,
`<video poster="${P('poster.png')}" src="${P('clip.mp4')}"></video>`,
`<div style="background-image:url(&quot;${P('q.png')}&quot;)"></div>`,
`<img src="/api/site-builder.php?action=serve_asset&amp;site_id=7&amp;filename=old.png">`,
].join('');
const hits = collectPreviewAssetUrls(html, { origin: O, siteId: 7 });
expect(hits.map((h) => new URL(h.key).searchParams.get('filename'))).toEqual(
['fav.png', 'css.png', 'bare.png', 'abs.png', 'a1.png', 'a2.png', 'poster.png', 'clip.mp4', 'q.png', 'old.png']);
// the &quot; after the URL is not swallowed, the &amp; form is decoded for fetching
const q = hits.find((h) => h.raw.includes('q.png'))!;
expect(html.slice(q.end, q.end + 6)).toBe('&quot;');
expect(hits[hits.length - 1].key).toBe(`${O}/api/site-builder.php?action=serve_asset&site_id=7&filename=old.png`);
});
it('leaves links, meta values, other sites, other origins, off-origin protocol-relative and plain assets/ paths alone', () => {
const html = [
`<a href="${P('link.png')}">x</a>`,
`<meta property="og:image" content="${O}${P('og.png')}">`,
`<img src="${P('other.png', 8)}">`,
`<img src="https://evil.example${P('evil.png')}">`,
`<img src="//evil.example${P('pr.png')}">`,
`<img src="assets/plain.png">`,
].join('');
expect(collectPreviewAssetUrls(html, { origin: O, siteId: 7 })).toEqual([]);
// without a site id, other sites' proxy URLs on the same origin are collected
expect(collectPreviewAssetUrls(`<img src="${P('other.png', 8)}">`, { origin: O }).length).toBe(1);
// a protocol-relative URL on the panel's own host is same-origin
expect(collectPreviewAssetUrls(`<img src="//panel.example:8443${P('pr.png')}">`, { origin: O, siteId: 7 })[0].key).toBe(`${O}${P('pr.png')}`);
});
it('a trailing query parameter is part of the URL (replaced with it), a closing &quot; is not', () => {
const html = `<div style="background:url(&quot;${P('a.png')}&amp;v=2&quot;)"></div>`;
const [h] = collectPreviewAssetUrls(html, { origin: O, siteId: 7 });
expect(h.raw).toBe(`${P('a.png')}&amp;v=2`);
expect(h.key).toBe(`${O}${P('a.png')}&v=2`);
});
it('decodes nothing it cannot: a php-urlencoded name is fetched exactly as written', () => {
const [h] = collectPreviewAssetUrls(`<img src="${P('my+photo%2Bv2.png')}">`, { origin: O, siteId: '7' });
expect(h.key).toBe(`${O}${P('my+photo%2Bv2.png')}`);
});
});
describe('inlinePreviewAssets', () => {
it('replaces each occurrence with a data: URL, fetching each distinct asset once, keeping the rest byte-identical', async () => {
const f = fakeFetch({ [`${O}${P('a.png')}`]: {} });
const html = `<head><script>/*head code*/</script></head><img src="${P('a.png')}"><div style="background:url('${O}${P('a.png')}')"></div>`;
const r = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f });
expect(f).toHaveBeenCalledTimes(1);
expect(r.html).toBe(`<head><script>/*head code*/</script></head><img src="${PNG_DATA}"><div style="background:url('${PNG_DATA}')"></div>`);
expect(r).toMatchObject({ found: 1, inlined: 1, skipped: 0 });
});
it('a failing asset keeps its URL; the others are still inlined', async () => {
const f = fakeFetch({
[`${O}${P('ok.png')}`]: {},
[`${O}${P('err.png')}`]: { status: 500 },
[`${O}${P('net.png')}`]: 'throw',
[`${O}${P('html.png')}`]: { type: 'text/html' },
[`${O}${P('empty.png')}`]: { body: new Uint8Array() },
});
const html = ['ok', 'err', 'net', 'missing', 'html', 'empty'].map((n) => `<img src="${P(n + '.png')}">`).join('');
const r = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f });
expect(r.html).toBe(`<img src="${PNG_DATA}">` + ['err', 'net', 'missing', 'html', 'empty'].map((n) => `<img src="${P(n + '.png')}">`).join(''));
expect(r).toMatchObject({ found: 6, inlined: 1, skipped: 5 });
});
it('honours the per-asset, total and count caps', async () => {
const big = new Uint8Array(100);
const f = fakeFetch({
[`${O}${P('a.png')}`]: { body: big }, [`${O}${P('b.png')}`]: { body: big },
[`${O}${P('c.png')}`]: { body: big, len: '100' }, [`${O}${P('d.png')}`]: {},
});
const html = ['a', 'b', 'c', 'd'].map((n) => `<img src="${P(n + '.png')}">`).join('');
const perAsset = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { maxAssetBytes: 50 } });
expect(perAsset.inlined).toBe(1); // only d (7 bytes)
const total = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { maxTotalBytes: 150, concurrency: 1 } });
expect(total.inlined).toBe(2); // a (100) + d (7); b and c would pass 150
const count = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { maxAssets: 2 } });
expect(count.inlined).toBe(2);
expect(count.html.endsWith(`<img src="${P('c.png')}"><img src="${P('d.png')}">`)).toBe(true);
});
it('times out a hung request and leaves its URL', async () => {
const f = vi.fn((_u: string, init?: RequestInit) => new Promise<Response>((_res, rej) => {
init?.signal?.addEventListener('abort', () => rej(new DOMException('aborted', 'AbortError')));
})) as unknown as typeof fetch;
const html = `<img src="${P('hang.png')}">`;
const r = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { timeoutMs: 20 } });
expect(r.html).toBe(html);
});
it('no proxy URL: no fetch at all, input returned as is', async () => {
const f = fakeFetch({});
const html = '<img src="https://cdn.example/x.png"><img src="assets/y.png">';
expect((await inlinePreviewAssets(html, { origin: O, fetchImpl: f })).html).toBe(html);
expect(f).not.toHaveBeenCalled();
});
});
+219
View File
@@ -0,0 +1,219 @@
/**
* Preview-only: inline the site's uploaded assets as `data:` URLs before the page is
* handed to the sandboxed Preview frame.
*
* Why: the editor stores an upload as the panel proxy URL
* (`/api/assets-proxy/?site_id=N&filename=F`, or the older
* `/api/site-builder.php?action=serve_asset&site_id=N&filename=F`). Every asset response
* carries `Cross-Origin-Resource-Policy: same-site` (whp libs/SiteBuilderAssetServe.php,
* a deliberate control: customer files must not be embeddable cross-site). Preview
* renders in `<iframe sandbox srcdoc>` WITHOUT allow-same-origin (sandboxed-preview.ts),
* so the frame has an opaque origin, every request it makes is cross-site, and the
* browser blocks the image (ERR_BLOCKED_BY_RESPONSE.NotSameSite, naturalWidth 0).
*
* Fix, without touching CORP or the sandbox: the editor (panel origin, same-origin to the
* proxy) fetches each referenced proxy asset itself and swaps the URL for a `data:` URL
* in the HTML text. Measured in Chromium and Firefox (scripts/e2e-preview-assets.mjs):
* inside the null-origin srcdoc frame a `data:` image loads; a `blob:` URL does NOT, whether
* created by the editor window or by the Preview tab (both are panel-origin blob URLs, and
* a blob URL only resolves for its own origin), so blob: is not an option here.
*
* Scope and limits:
* - only same-origin proxy URLs (root-relative, or absolute on `origin`) for `siteId`
* (when given) are touched; anything else -- other sites, other hosts, plain
* `assets/x` paths, `<a href>` links, `<meta content>` -- is left byte-for-byte;
* - the HTML is edited as text (no parse/reserialise), so everything else in the
* document, including customer head code, is unchanged;
* - per-asset, total-byte, count and time caps; any failure (HTTP error, wrong type,
* over a cap, timeout) leaves THAT URL as it was -- no worse than before;
* - the result is only ever given to Preview; Save/Publish/export never call this,
* so published output is unaffected.
* data: URLs are plain strings, so there is nothing to revoke: they go away with the
* Preview tab's document.
*/
import { openSandboxedPreview } from './sandboxed-preview';
export interface InlineLimits {
/** Largest single asset inlined (bytes). Bigger ones keep their URL. */
maxAssetBytes: number;
/** Total bytes inlined per Preview. Assets past it keep their URL. */
maxTotalBytes: number;
/** Most distinct assets fetched per Preview. */
maxAssets: number;
/** Per-request timeout (ms). */
timeoutMs: number;
/** Requests in flight at once. */
concurrency: number;
}
export const DEFAULT_INLINE_LIMITS: InlineLimits = {
maxAssetBytes: 8 * 1024 * 1024,
maxTotalBytes: 40 * 1024 * 1024,
maxAssets: 200,
timeoutMs: 15000,
concurrency: 6,
};
/** Types we will put in a data: URL. The proxy's own allowlist decides what it serves; this is belt and braces. */
const INLINE_TYPES = new Set([
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml',
'image/x-icon', 'image/vnd.microsoft.icon', 'image/bmp',
'video/mp4', 'video/webm', 'video/ogg',
]);
export interface InlineOptions {
/** The panel origin (window.location.origin). Absolute proxy URLs on any other origin are left alone. */
origin: string;
/** Only this site's assets are inlined (WHP_CONFIG.siteId); undefined/null = any site id. */
siteId?: string | number | null;
limits?: Partial<InlineLimits>;
/** Test seam; defaults to the global fetch. */
fetchImpl?: typeof fetch;
}
export interface InlineResult {
html: string;
/** Distinct asset URLs found / inlined / left as they were. */
found: number;
inlined: number;
skipped: number;
}
// One proxy URL, same grammar as web-files/libs/SiteBuilderAssetUrls.php and editor-asset-urls.ts PROXY_URL,
// but tighter at the end: the filename stops at `&`, whitespace, quotes, parens, `<>`, `,` (srcset) and `\`;
// further `&key=value` parameters (e.g. a cache-buster) belong to the URL and are replaced with it, while an
// entity such as the `&quot;` closing a style url() has no `=` and is never swallowed.
const PROXY_RE = /(?:(?:https?:)?\/\/[^/"'\s()<>]+)?\/api\/(?:assets-proxy\/\?|site-builder\.php\?action=serve_asset(?:&amp;|&))site_id=([^&"'\s()<>]+)(?:&amp;|&)filename=([^&"'\s()<>,\\]+)(?:(?:&amp;|&)[A-Za-z0-9_]+=[^&"'\s()<>,\\]*)*/g;
interface Hit { start: number; end: number; raw: string; key: string }
/** True when the match at `start` is the value of an attribute we must not rewrite (a link target, a meta value). */
function inSkippedAttr(html: string, start: number): boolean {
const before = html.slice(Math.max(0, start - 64), start);
const m = /\b(href|content|action|formaction)\s*=\s*["']?$/i.exec(before);
if (!m) return false;
if (m[1].toLowerCase() !== 'href') return true;
// <link href> (favicon, preload) is a resource; <a href>/<area href> is a link and stays a link.
const tagStart = html.lastIndexOf('<', start);
return !(tagStart >= 0 && /^<link\b/i.test(html.slice(tagStart, tagStart + 6)));
}
/**
* Every rewritable proxy URL in `html`, in document order, with the absolute URL to fetch (`key`).
* Exported for tests.
*/
export function collectPreviewAssetUrls(html: string, opts: Pick<InlineOptions, 'origin' | 'siteId'>): Hit[] {
const hits: Hit[] = [];
if (!html.includes('/api/')) return hits;
const want = opts.siteId === undefined || opts.siteId === null || opts.siteId === '' ? null : String(opts.siteId);
PROXY_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PROXY_RE.exec(html))) {
const raw = m[0];
const start = m.index;
if (want !== null && m[1] !== want) continue;
if (inSkippedAttr(html, start)) continue;
const decoded = raw.replace(/&amp;/g, '&');
let abs: URL;
try { abs = new URL(decoded, opts.origin + '/'); } catch { continue; }
if (abs.origin !== opts.origin) continue;
hits.push({ start, end: start + raw.length, raw, key: abs.href });
}
return hits;
}
async function toDataUrl(buf: ArrayBuffer, type: string): Promise<string> {
const bytes = new Uint8Array(buf);
let bin = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]);
}
return `data:${type};base64,${btoa(bin)}`;
}
/** Fetch one asset; null when it must keep its URL. `reserve` claims bytes against the total cap. */
async function fetchOne(url: string, lim: InlineLimits, f: typeof fetch, reserve: (n: number) => boolean): Promise<string | null> {
const ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = ctl ? setTimeout(() => ctl.abort(), lim.timeoutMs) : null;
try {
const res = await f(url, { credentials: 'same-origin', cache: 'force-cache', signal: ctl?.signal, redirect: 'error' });
if (!res.ok) return null;
const type = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!INLINE_TYPES.has(type)) return null;
const declared = Number(res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > lim.maxAssetBytes) { ctl?.abort(); return null; }
const buf = await res.arrayBuffer();
if (buf.byteLength === 0 || buf.byteLength > lim.maxAssetBytes) return null;
if (!reserve(buf.byteLength)) return null;
return await toDataUrl(buf, type);
} catch {
return null;
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Return `html` with this site's same-origin proxy asset URLs replaced by `data:` URLs (see the file header).
* Never throws for a per-asset problem; the worst case is the input unchanged.
*/
export async function inlinePreviewAssets(html: string, opts: InlineOptions): Promise<InlineResult> {
const lim: InlineLimits = { ...DEFAULT_INLINE_LIMITS, ...(opts.limits || {}) };
const f = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
const hits = collectPreviewAssetUrls(html, opts);
const keys: string[] = [];
const seen = new Set<string>();
for (const h of hits) if (!seen.has(h.key)) { seen.add(h.key); keys.push(h.key); }
if (!f || keys.length === 0) return { html, found: keys.length, inlined: 0, skipped: keys.length };
const todo = keys.slice(0, Math.max(0, lim.maxAssets));
const data = new Map<string, string>();
let total = 0;
// Assets are started in document order; bytes are granted first come, first served up to the total cap.
const reserve = (n: number): boolean => {
if (total + n > lim.maxTotalBytes) return false;
total += n;
return true;
};
let next = 0;
const worker = async (): Promise<void> => {
while (next < todo.length) {
const key = todo[next++];
const d = await fetchOne(key, lim, f, reserve);
if (d) data.set(key, d);
}
};
await Promise.all(Array.from({ length: Math.max(1, Math.min(lim.concurrency, todo.length)) }, worker));
if (data.size === 0) return { html, found: keys.length, inlined: 0, skipped: keys.length };
let out = '';
let pos = 0;
for (const h of hits) {
const d = data.get(h.key);
if (!d) continue;
out += html.slice(pos, h.start) + d;
pos = h.end;
}
out += html.slice(pos);
return { html: out, found: keys.length, inlined: data.size, skipped: keys.length - data.size };
}
/**
* Preview entry point: inline the assets, then render in the sandboxed tab. Any failure of the inlining
* itself falls back to the HTML as given (today's behaviour). When `win` (the tab opened inside the
* click) was closed while the assets were loading, nothing is opened.
*/
export async function openPreviewWithInlinedAssets(
html: string,
opts: InlineOptions & { title?: string; win?: Window | null },
): Promise<Window | null> {
let out = html;
try {
out = (await inlinePreviewAssets(html, opts)).html;
} catch (e) {
console.warn('Preview: could not inline assets', e);
}
if (opts.win && opts.win.closed) return null;
return openSandboxedPreview(out, { title: opts.title, win: opts.win });
}