Files
site-builder/craft/scripts/e2e-preview-assets.mjs
T
shadowdaoandClaude Opus 5.5 8ff0e01c45 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
2026-11-02 20:29:36 -08:00

194 lines
14 KiB
JavaScript

#!/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);