Sitesmith self-review capture: sanitise page HTML before it reaches the frame (inert DOMParser allow-list, fixed-point guard), CSP + script-less sandbox, real-browser hostile-page proof; UI building blocks (attachments, sites I like, asset awareness, design payload)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fde4dc22f
commit
197b9c46ab
14 files changed
+965
-10
No files matched your search
@@ -0,0 +1,16 @@
|
||||
// Bundled by scripts/e2e-capture-security.mjs and run in real Firefox + Chromium.
|
||||
import { captureHtml } from '../src/utils/self-review-capture';
|
||||
import { captureCsp } from '../src/utils/sanitize-capture-html';
|
||||
|
||||
(window as any).captureHtml = captureHtml;
|
||||
/** Defence-in-depth probe: the capture frame WITHOUT the sanitiser (sandbox + CSP only). */
|
||||
(window as any).rawFrame = (html: string) => new Promise<void>((resolve) => {
|
||||
const f = document.createElement('iframe');
|
||||
f.setAttribute('sandbox', 'allow-same-origin');
|
||||
const CSP = captureCsp(location.origin);
|
||||
f.setAttribute('csp', CSP);
|
||||
f.style.cssText = 'position:fixed;left:-9999px;width:800px;height:600px';
|
||||
f.addEventListener('load', () => setTimeout(resolve, 800), { once: true });
|
||||
f.srcdoc = html.replace('<head>', `<head><meta http-equiv="Content-Security-Policy" content="${CSP}">`);
|
||||
document.body.appendChild(f);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser proof (Firefox + Chromium) that the self-review capture cannot run or leak hostile page
|
||||
* markup. A malicious fixture goes through the WHOLE capture path; sentinels: window.__pwned in the
|
||||
* parent, any request to /beacon or evil.example, parent cookie/localStorage reachability, frame
|
||||
* navigation. A second probe feeds the SAME hostile page to the frame WITHOUT the sanitiser to show the
|
||||
* sandbox + CSP alone also hold. Benign pages must still capture.
|
||||
* Run: node scripts/e2e-capture-security.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries)
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { chromium, firefox } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const out = await build({
|
||||
entryPoints: [path.join(root, 'scripts/e2e-capture-security-entry.ts')],
|
||||
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
});
|
||||
const js = out.outputFiles[0].text;
|
||||
|
||||
const PWN = "(function(){try{parent.__pwned=1;top.__pwned=1}catch(e){}try{fetch('/beacon?js')}catch(e){}try{top.postMessage('pwned','*')}catch(e){}try{new Image().src='/beacon?img-js'}catch(e){}})()";
|
||||
const EVIL = `<!doctype html><html><head>
|
||||
<meta http-equiv="refresh" content="0;url=/beacon?refresh"><base href="https://evil.example/">
|
||||
<link rel="import" href="/beacon?import"><link rel="stylesheet" href="/beacon?css"><link rel="preload" href="/beacon?preload" as="script">
|
||||
<style>@import url(/beacon?atimport); body{background:url(https://evil.example/bg.png);} .pad{padding:8px}</style>
|
||||
<script>${PWN}</script></head>
|
||||
<body onload="${PWN}" style="font:16px sans-serif">
|
||||
<h1 class="pad" style="color:#123">Hostile but readable</h1>
|
||||
<img src="x" onerror="${PWN}"><img src="/beacon?img">
|
||||
<svg width="10" height="10" onload="${PWN}"><script>${PWN}</script><a xlink:href="javascript:${PWN}"><rect width="5" height="5"/></a></svg>
|
||||
<a href="javascript:${PWN}">js link</a><form action="javascript:${PWN}"><button formaction="javascript:${PWN}">b</button></form>
|
||||
<iframe srcdoc="<script>${PWN}</script>"></iframe><iframe src="/beacon?iframe"></iframe>
|
||||
<object data="/beacon?object"></object><embed src="/beacon?embed">
|
||||
<div style="background:url(/beacon?cssurl)">x</div><video src="/beacon?video"></video>
|
||||
<input autofocus onfocus="${PWN}"><details open ontoggle="${PWN}"><summary>s</summary></details>
|
||||
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
|
||||
</body></html>`;
|
||||
const BENIGN = `<!doctype html><html><head><style>body{margin:0;font:20px sans-serif}.h{background:linear-gradient(135deg,#0f172a,#3b82f6);color:#fff;padding:40px}</style></head>
|
||||
<body><div class="h"><h1>Benign page</h1><p>${'Hello world. '.repeat(30)}</p></div></body></html>`;
|
||||
|
||||
const beacons = [];
|
||||
const srv = http.createServer((q, r) => {
|
||||
const p = q.url.split('?')[0];
|
||||
if (p === '/') { r.setHeader('content-type', 'text/html'); return r.end('<!doctype html><title>host</title><body>host</body>'); }
|
||||
if (p === '/beacon') { beacons.push(q.url); r.statusCode = 204; return r.end(); }
|
||||
r.statusCode = 404; r.end('nf');
|
||||
}).listen(0);
|
||||
const port = srv.address().port;
|
||||
|
||||
let failed = 0;
|
||||
const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); };
|
||||
|
||||
async function run(name, launcher, exe) {
|
||||
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const evilReq = [];
|
||||
await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); });
|
||||
// Only requests that actually reach the network count (Playwright also reports CSP-blocked ones as 'request').
|
||||
let messages = 0;
|
||||
await page.exposeFunction('__onMsg', () => { messages++; });
|
||||
await page.goto(`http://localhost:${port}/`);
|
||||
await page.evaluate(() => { document.cookie = 'sess=SECRET; path=/'; localStorage.setItem('csrf', 'SECRET'); window.addEventListener('message', (e) => { if (e.data === 'pwned') window.__onMsg(); }); });
|
||||
await page.addScriptTag({ content: js });
|
||||
|
||||
// 1) hostile page through the whole capture path
|
||||
beacons.length = 0;
|
||||
const res = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size } : r; }, EVIL);
|
||||
await page.waitForTimeout(500);
|
||||
const pwned = await page.evaluate(() => window.__pwned);
|
||||
check(name, 'hostile page: parent sentinel window.__pwned untouched', pwned === undefined, `(${pwned})`);
|
||||
check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`);
|
||||
check(name, 'hostile page: no request to the panel origin /beacon', beacons.length === 0, JSON.stringify(beacons));
|
||||
check(name, 'hostile page: no request to evil.example', evilReq.length === 0, JSON.stringify(evilReq));
|
||||
check(name, 'hostile page: capture result is safe (ok, or refused) and frame removed', (res.ok || typeof res.reason === 'string') && (await page.evaluate(() => document.querySelectorAll('iframe').length)) === 0, JSON.stringify(res));
|
||||
check(name, 'hostile page: sanitised content still captured (readable page)', res.ok === true && res.size > 3000, JSON.stringify(res));
|
||||
|
||||
// 2) defence in depth: same hostile page, NO sanitiser, only sandbox + CSP
|
||||
beacons.length = 0; evilReq.length = 0; messages = 0;
|
||||
await page.evaluate((h) => window.rawFrame(h), EVIL);
|
||||
await page.waitForTimeout(500);
|
||||
const pwned2 = await page.evaluate(() => window.__pwned);
|
||||
check(name, 'unsanitised probe: sandbox+CSP alone stop script execution (__pwned)', pwned2 === undefined, `(${pwned2})`);
|
||||
check(name, 'unsanitised probe: no postMessage', messages === 0);
|
||||
// Same-origin passive GETs (img/css url) are allowed by CSP 'self'; that is why the sanitiser also restricts them. Script-initiated ones must never occur.
|
||||
check(name, 'unsanitised probe: no script-initiated requests (js/img-js beacons)', !beacons.some((b) => /\?(js|img-js|refresh|import|iframe|object|embed|video)$/.test(b)), JSON.stringify(beacons));
|
||||
check(name, 'unsanitised probe: no evil.example requests', evilReq.length === 0, JSON.stringify(evilReq));
|
||||
await page.evaluate(() => document.querySelectorAll('iframe').forEach((f) => f.remove()));
|
||||
|
||||
// 3) benign page still captures
|
||||
const ok = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size, w: r.width, h: r.height } : r; }, BENIGN);
|
||||
check(name, 'benign page captures', ok.ok === true && ok.size > 3000, JSON.stringify(ok));
|
||||
await browser.close();
|
||||
}
|
||||
await run('chromium', chromium, process.env.CHROMIUM_PATH);
|
||||
await run('firefox', firefox, process.env.FIREFOX_PATH);
|
||||
srv.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -61,6 +61,10 @@ export interface SitesmithSendOpts {
|
||||
mockupIds?: string[];
|
||||
referenceIds?: number[];
|
||||
useAssets?: boolean;
|
||||
/** Current site design (see utils/sitesmith-design.ts); read by the backend prompt builder. */
|
||||
design?: Record<string, unknown>;
|
||||
/** Uploaded assets Sitesmith may see (relative filenames). Omitted = all. Needs backend support; see report. */
|
||||
assetFilenames?: string[];
|
||||
onProgress?: (phase: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -332,6 +336,7 @@ export function useSitesmith(siteId: number) {
|
||||
if (target) body.target = target;
|
||||
if (profile) body.profile = profile;
|
||||
if (opts?.intent) body.intent = opts.intent;
|
||||
if (opts?.design && Object.keys(opts.design).length) body.design = opts.design;
|
||||
|
||||
let result: SendResult;
|
||||
let usedStart = false;
|
||||
@@ -340,6 +345,7 @@ export function useSitesmith(siteId: number) {
|
||||
if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds;
|
||||
if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds;
|
||||
if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets;
|
||||
if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames;
|
||||
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import type { SitesmithAsset } from '../../hooks/useSitesmith';
|
||||
import { note, thumb, errBox } from './Section';
|
||||
|
||||
interface Props {
|
||||
assets: SitesmithAsset[];
|
||||
loaded: boolean;
|
||||
error?: string | null;
|
||||
/** Filenames Sitesmith may see. */
|
||||
included: Set<string>;
|
||||
onToggle: (filename: string) => void;
|
||||
onAll: (on: boolean) => void;
|
||||
/** Public-safe display URL for a thumbnail. */
|
||||
thumbUrl: (a: SitesmithAsset) => string;
|
||||
visionAvailable: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Which uploaded images Sitesmith will see, with the captions it has, and a per-image toggle. */
|
||||
export const AssetAwareness: React.FC<Props> = ({ assets, loaded, error, included, onToggle, onAll, thumbUrl, visionAvailable, disabled }) => (
|
||||
<div data-testid="ss-assets">
|
||||
<p style={note}>
|
||||
Sitesmith can use the images you uploaded to this site and place them on the page. Untick any you do not want it to use.
|
||||
{visionAvailable
|
||||
? ' It looks at each picture (a small copy) and writes a short description the first time.'
|
||||
: ' Image viewing is not available on this server, so Sitesmith works from file names and sizes only.'}
|
||||
</p>
|
||||
{error && <div style={errBox} role="alert">{error}</div>}
|
||||
{!loaded && !error && <p style={note}>Loading your images…</p>}
|
||||
{loaded && !error && assets.length === 0 && <p style={note}>You have not uploaded any images yet. Add some in the Assets tab and Sitesmith will be able to use them.</p>}
|
||||
{assets.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 6, fontSize: 12 }}>
|
||||
<button type="button" disabled={disabled} onClick={() => onAll(true)} style={{ background: 'none', border: 'none', color: '#a78bfa', cursor: 'pointer', padding: 0, marginRight: 12 }}>Include all</button>
|
||||
<button type="button" disabled={disabled} onClick={() => onAll(false)} style={{ background: 'none', border: 'none', color: '#a78bfa', cursor: 'pointer', padding: 0 }}>Include none</button>
|
||||
<span style={{ color: '#71717a', marginLeft: 12 }}>{included.size} of {assets.length} included</span>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 220, overflowY: 'auto' }}>
|
||||
{assets.map((a) => (
|
||||
<li key={a.filename} data-testid="ss-asset-item" style={{ display: 'flex', gap: 8, alignItems: 'center', opacity: included.has(a.filename) ? 1 : 0.5 }}>
|
||||
<input type="checkbox" checked={included.has(a.filename)} disabled={disabled} onChange={() => onToggle(a.filename)} aria-label={`Let Sitesmith use ${a.filename}`} />
|
||||
<img src={thumbUrl(a)} alt="" loading="lazy" style={{ ...thumb, width: 40, height: 40 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: '#e4e4e7', overflowWrap: 'anywhere' }}>{a.filename}{a.width && a.height ? <span style={{ color: '#71717a' }}> · {a.width}×{a.height}</span> : null}</div>
|
||||
<div style={{ color: '#a1a1aa', fontSize: 12 }}>{a.caption || a.alt || 'No description yet'}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type { SitesmithRefImage } from '../../hooks/useSitesmith';
|
||||
import { formatBytes } from '../../utils/sitesmith-attachments';
|
||||
import { btn, btnDanger, errBox, note, thumb, warnBox } from './Section';
|
||||
|
||||
interface Props {
|
||||
/** Mockup images already stored for this site (from refs_list). */
|
||||
images: SitesmithRefImage[];
|
||||
selected: string[];
|
||||
onSelectedChange: (ids: string[]) => void;
|
||||
/** Total image budget for one send (mockups + reference screenshots). */
|
||||
maxAttach: number;
|
||||
/** Images already counted by selected "Sites I like" entries. */
|
||||
referenceImageCount: number;
|
||||
imageUrl: (id: string) => string;
|
||||
onUpload: (file: File) => Promise<{ ok: true; image?: SitesmithRefImage } | { ok: false; message: string }>;
|
||||
onRemove: (id: string) => Promise<{ ok: true } | { ok: false; message: string }>;
|
||||
/** Image features off: why (vision unavailable). Text-only generation is unaffected. */
|
||||
unavailableReason?: string | null;
|
||||
disabled?: boolean;
|
||||
/** Called with the message when a file is rejected or an upload fails. */
|
||||
}
|
||||
|
||||
/** Mockup picker: drag and drop or file picker, thumbnails, remove. Image names are rendered as text nodes only. */
|
||||
export const AttachmentTray: React.FC<Props> = ({
|
||||
images, selected, onSelectedChange, maxAttach, referenceImageCount, imageUrl, onUpload, onRemove, unavailableReason, disabled,
|
||||
}) => {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const mockups = images.filter((i) => i.kind === 'mockup');
|
||||
const off = !!unavailableReason || !!disabled;
|
||||
const used = selected.length + referenceImageCount;
|
||||
|
||||
const addFiles = async (files: FileList | File[] | null) => {
|
||||
if (!files || off) return;
|
||||
setErr(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const f of Array.from(files)) {
|
||||
if (used + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; }
|
||||
const r = await onUpload(f);
|
||||
if (!r.ok) { setErr(r.message); break; }
|
||||
if (r.image) onSelectedChange([...selected, r.image.id]);
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (selected.includes(id)) onSelectedChange(selected.filter((x) => x !== id));
|
||||
else if (used < maxAttach) onSelectedChange([...selected, id]);
|
||||
else setErr(`You can attach up to ${maxAttach} images to one request.`);
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setErr(null);
|
||||
const r = await onRemove(id);
|
||||
if (!r.ok) { setErr(r.message); return; }
|
||||
onSelectedChange(selected.filter((x) => x !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="ss-mockups">
|
||||
<p style={note}>
|
||||
Attach a picture of the page you want (a sketch, a screenshot, a design export). Sitesmith builds toward it.
|
||||
The image is kept privately for Sitesmith; it is never published or added to your assets.
|
||||
</p>
|
||||
{unavailableReason && <div style={warnBox} role="note" data-testid="ss-vision-note">{unavailableReason}</div>}
|
||||
<div
|
||||
data-testid="ss-dropzone"
|
||||
onDragOver={(e) => { if (off) return; e.preventDefault(); setDrag(true); }}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDrag(false); void addFiles(e.dataTransfer?.files ?? null); }}
|
||||
style={{
|
||||
border: `1px dashed ${drag ? '#a78bfa' : '#3f3f46'}`, borderRadius: 8, padding: 12, textAlign: 'center',
|
||||
background: drag ? 'rgba(124,58,237,0.12)' : 'transparent', opacity: off ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<button type="button" style={btn} disabled={off || busy} onClick={() => input.current?.click()}>
|
||||
{busy ? 'Uploading…' : 'Choose an image'}
|
||||
</button>
|
||||
<span style={{ color: '#a1a1aa', fontSize: 12, marginLeft: 8 }}>or drop it here (JPEG, PNG, WebP or GIF, up to 8 MB)</span>
|
||||
<input
|
||||
ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/gif" hidden multiple
|
||||
data-testid="ss-mockup-input" disabled={off}
|
||||
onChange={(e) => { void addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
</div>
|
||||
{err && <div role="alert" style={errBox}>{err}</div>}
|
||||
{mockups.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: '10px 0 0', padding: 0, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{mockups.map((im) => {
|
||||
const on = selected.includes(im.id);
|
||||
return (
|
||||
<li key={im.id} style={{ width: 96 }} data-testid="ss-mockup-item">
|
||||
<label style={{ display: 'block', cursor: 'pointer' }}>
|
||||
<img src={imageUrl(im.id)} alt={`Mockup ${im.name}`} style={{ ...thumb, width: 96, height: 72, outline: on ? '2px solid #a78bfa' : 'none' }} />
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#d4d4d8', marginTop: 3 }}>
|
||||
<input type="checkbox" checked={on} disabled={off} onChange={() => toggle(im.id)} aria-label={`Use ${im.name} for the next request`} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{im.name}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 10, color: '#71717a' }}>{im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}</div>
|
||||
<button type="button" style={{ ...btnDanger, marginTop: 3, padding: '2px 6px', fontSize: 11 }} disabled={off} onClick={() => void remove(im.id)} aria-label={`Remove ${im.name}`}>Remove</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
const sectionStyle: React.CSSProperties = { border: '1px solid #27272a', borderRadius: 8, marginBottom: 8, background: '#13131c' };
|
||||
const headStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'transparent', border: 'none', color: '#e4e4e7', padding: '8px 12px', cursor: 'pointer', fontSize: 13, fontWeight: 600, textAlign: 'left' };
|
||||
const bodyStyle: React.CSSProperties = { padding: '4px 12px 12px', fontSize: 13, color: '#d4d4d8' };
|
||||
|
||||
/** Collapsible block used by the Sitesmith context area. */
|
||||
export const Section: React.FC<{
|
||||
id: string; title: string; icon?: string; badge?: string; open: boolean; onToggle: () => void; children: React.ReactNode;
|
||||
}> = ({ id, title, icon, badge, open, onToggle, children }) => (
|
||||
<div style={sectionStyle} data-testid={`ss-section-${id}`}>
|
||||
<button type="button" style={headStyle} aria-expanded={open} aria-controls={`ss-sec-${id}`} onClick={onToggle}>
|
||||
{icon && <i className={`fa ${icon}`} aria-hidden style={{ color: '#a78bfa', width: 16 }} />}
|
||||
<span>{title}</span>
|
||||
{badge && <span style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 400 }}>{badge}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<i className={`fa fa-chevron-${open ? 'up' : 'down'}`} aria-hidden style={{ color: '#71717a', fontSize: 11 }} />
|
||||
</button>
|
||||
{open && <div id={`ss-sec-${id}`} style={bodyStyle}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const note: React.CSSProperties = { fontSize: 12, color: '#a1a1aa', lineHeight: 1.5, margin: '4px 0 8px' };
|
||||
export const warnBox: React.CSSProperties = { background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fde68a', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' };
|
||||
export const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' };
|
||||
export const btn: React.CSSProperties = { background: 'transparent', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' };
|
||||
export const btnPrimary: React.CSSProperties = { background: '#7c3aed', color: '#fff', border: '1px solid #7c3aed', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' };
|
||||
export const btnDanger: React.CSSProperties = { background: 'transparent', color: '#fca5a5', border: '1px solid #7f1d1d', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' };
|
||||
export const inputStyle: React.CSSProperties = { width: '100%', boxSizing: 'border-box', background: '#0f0f17', color: '#e5e7eb', border: '1px solid #3f3f46', borderRadius: 6, padding: '6px 8px', fontSize: 13 };
|
||||
export const thumb: React.CSSProperties = { width: 64, height: 64, objectFit: 'cover', borderRadius: 6, border: '1px solid #3f3f46', background: '#0f0f17', display: 'block' };
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type { SitesmithReference, SitesmithRefImage } from '../../hooks/useSitesmith';
|
||||
import { btn, btnDanger, btnPrimary, errBox, inputStyle, note, thumb, warnBox } from './Section';
|
||||
import type { Outcome } from './useSitesmithRefs';
|
||||
|
||||
interface Props {
|
||||
references: SitesmithReference[];
|
||||
images: SitesmithRefImage[];
|
||||
selected: number[];
|
||||
onSelectedChange: (ids: number[]) => void;
|
||||
imageUrl: (id: string) => string;
|
||||
loadError?: string | null;
|
||||
addRef: (url: string, likes: string, shots: string[]) => Promise<Outcome>;
|
||||
updateRef: (id: number, url: string, likes: string, shots: string[]) => Promise<Outcome>;
|
||||
deleteRef: (id: number) => Promise<Outcome>;
|
||||
uploadShot: (f: File) => Promise<Outcome & { image?: SitesmithRefImage }>;
|
||||
removeImage: (id: string) => Promise<Outcome>;
|
||||
maxRefs: number;
|
||||
/** Image features unavailable (no vision): text-only entries still work. */
|
||||
imagesUnavailableReason?: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MAX_SHOTS = 3;
|
||||
|
||||
interface FormState { id: number | null; url: string; likes: string; shots: string[]; added: string[] }
|
||||
const EMPTY: FormState = { id: null, url: '', likes: '', shots: [], added: [] };
|
||||
|
||||
/** "Sites I like": a per-site list of label + notes + optional screenshots. The address is a label and is never fetched. */
|
||||
export const SitesILikePanel: React.FC<Props> = ({
|
||||
references, images, selected, onSelectedChange, imageUrl, loadError, addRef, updateRef, deleteRef, uploadShot, removeImage,
|
||||
maxRefs, imagesUnavailableReason, disabled,
|
||||
}) => {
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const imgById = new Map(images.map((i) => [i.id, i]));
|
||||
|
||||
const startAdd = () => { setErr(null); setForm({ ...EMPTY }); };
|
||||
const startEdit = (r: SitesmithReference) => { setErr(null); setForm({ id: r.id, url: r.url_label, likes: r.what_i_like, shots: [...r.screenshot_ids], added: [] }); };
|
||||
|
||||
const cancel = async () => {
|
||||
// Screenshots uploaded for a form that was never saved would be orphans: drop them.
|
||||
if (form) for (const id of form.added) await removeImage(id);
|
||||
setForm(null); setErr(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const r = form.id === null
|
||||
? await addRef(form.url, form.likes, form.shots)
|
||||
: await updateRef(form.id, form.url, form.likes, form.shots);
|
||||
if (!r.ok) { setErr(r.message); return; }
|
||||
setForm(null);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const addShot = async (files: FileList | null) => {
|
||||
if (!files || !form) return;
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
for (const f of Array.from(files)) {
|
||||
if (form.shots.length >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; }
|
||||
const r = await uploadShot(f);
|
||||
if (!r.ok) { setErr(r.message); break; }
|
||||
const id = r.image!.id;
|
||||
setForm((s) => (s ? { ...s, shots: [...s.shots, id], added: [...s.added, id] } : s));
|
||||
form.shots = [...form.shots, id];
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const dropShot = async (id: string) => {
|
||||
if (!form) return;
|
||||
// Removing from a saved entry detaches it; an image added in this form is deleted outright.
|
||||
if (form.added.includes(id)) await removeImage(id);
|
||||
setForm((s) => (s ? { ...s, shots: s.shots.filter((x) => x !== id), added: s.added.filter((x) => x !== id) } : s));
|
||||
};
|
||||
|
||||
const del = async (id: number) => {
|
||||
setErr(null);
|
||||
const r = await deleteRef(id);
|
||||
setConfirmDel(null);
|
||||
if (!r.ok) { setErr(r.message); return; }
|
||||
onSelectedChange(selected.filter((x) => x !== id));
|
||||
};
|
||||
|
||||
const toggle = (id: number) => onSelectedChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
|
||||
|
||||
return (
|
||||
<div data-testid="ss-sites-i-like">
|
||||
<p style={note} data-testid="ss-sites-copy">
|
||||
List websites whose look you admire and say what you like about each. The address is only a label for you:
|
||||
<strong> Sitesmith does not open, visit or fetch it</strong>. It works from your description and any screenshots you attach yourself.
|
||||
</p>
|
||||
{loadError && <div style={errBox} role="alert">{loadError}</div>}
|
||||
{err && !form && <div style={errBox} role="alert">{err}</div>}
|
||||
|
||||
{references.length === 0 && !form && <p style={note}>No sites yet.</p>}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{references.map((r) => (
|
||||
<li key={r.id} data-testid="ss-ref-item" style={{ border: '1px solid #27272a', borderRadius: 6, padding: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<input type="checkbox" checked={selected.includes(r.id)} disabled={disabled} onChange={() => toggle(r.id)} aria-label={`Use ${r.url_label} for the next request`} style={{ marginTop: 3 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, color: '#e4e4e7', overflowWrap: 'anywhere' }}>{r.url_label}</div>
|
||||
<div style={{ color: '#a1a1aa', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{r.what_i_like}</div>
|
||||
{r.screenshot_ids.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
|
||||
{r.screenshot_ids.map((sid) => (
|
||||
<img key={sid} src={imageUrl(sid)} alt={`Screenshot ${imgById.get(sid)?.name ?? ''}`} style={{ ...thumb, width: 48, height: 48 }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" style={btn} disabled={disabled} onClick={() => startEdit(r)} aria-label={`Edit ${r.url_label}`}>Edit</button>
|
||||
{confirmDel === r.id
|
||||
? <button type="button" style={btnDanger} onClick={() => void del(r.id)} autoFocus>Confirm delete</button>
|
||||
: <button type="button" style={btnDanger} disabled={disabled} onClick={() => setConfirmDel(r.id)} aria-label={`Delete ${r.url_label}`}>Delete</button>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{form ? (
|
||||
<div style={{ marginTop: 10, border: '1px solid #3f3f46', borderRadius: 6, padding: 10 }} data-testid="ss-ref-form">
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#a1a1aa', marginBottom: 2 }} htmlFor="ss-ref-url">Site address (label only, not opened)</label>
|
||||
<input id="ss-ref-url" style={inputStyle} value={form.url} maxLength={300} placeholder="example.com" onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#a1a1aa', margin: '8px 0 2px' }} htmlFor="ss-ref-likes">What I like about it</label>
|
||||
<textarea id="ss-ref-likes" style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }} value={form.likes} maxLength={1000} placeholder="Big photo header, lots of white space, simple menu" onChange={(e) => setForm({ ...form, likes: e.target.value })} />
|
||||
<div style={{ fontSize: 12, color: '#a1a1aa', margin: '8px 0 4px' }}>Screenshots (optional, up to {MAX_SHOTS})</div>
|
||||
{imagesUnavailableReason
|
||||
? <div style={warnBox} role="note">{imagesUnavailableReason}</div>
|
||||
: (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{form.shots.map((sid) => (
|
||||
<span key={sid} style={{ position: 'relative' }}>
|
||||
<img src={imageUrl(sid)} alt="Screenshot" style={{ ...thumb, width: 56, height: 56 }} />
|
||||
<button type="button" onClick={() => void dropShot(sid)} aria-label="Remove screenshot" style={{ ...btnDanger, position: 'absolute', top: -6, right: -6, padding: '0 5px', fontSize: 11 }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
{form.shots.length < MAX_SHOTS && (
|
||||
<button type="button" style={btn} disabled={busy} onClick={() => fileRef.current?.click()}>Add screenshot</button>
|
||||
)}
|
||||
<input ref={fileRef} type="file" hidden multiple accept="image/jpeg,image/png,image/webp,image/gif" data-testid="ss-shot-input" onChange={(e) => { void addShot(e.target.files); e.target.value = ''; }} />
|
||||
</div>
|
||||
)}
|
||||
{err && <div style={errBox} role="alert">{err}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<button type="button" style={btnPrimary} disabled={busy} onClick={() => void save()}>{form.id === null ? 'Add site' : 'Save changes'}</button>
|
||||
<button type="button" style={btn} disabled={busy} onClick={() => void cancel()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" style={{ ...btn, marginTop: 10 }} disabled={disabled || references.length >= maxRefs} onClick={startAdd}>
|
||||
Add a site I like{references.length >= maxRefs ? ` (limit ${maxRefs} reached)` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { SitesmithReference, SitesmithRefImage, SitesmithApiResult } from '../../hooks/useSitesmith';
|
||||
import { validateAttachment, normalizeUrlLabel, DEFAULT_MAX_UPLOAD_BYTES } from '../../utils/sitesmith-attachments';
|
||||
|
||||
/** The slice of useSitesmith() this state needs (keeps it easy to fake in tests). */
|
||||
export interface RefsApi {
|
||||
listRefs: () => Promise<SitesmithApiResult<{ references: SitesmithReference[]; images: SitesmithRefImage[] }>>;
|
||||
addReference: (r: { url_label: string; what_i_like: string; screenshot_ids?: string[] }) => Promise<SitesmithApiResult<{ reference: SitesmithReference }>>;
|
||||
updateReference: (id: number, p: { url_label?: string; what_i_like?: string; screenshot_ids?: string[] }) => Promise<SitesmithApiResult<{ reference: SitesmithReference }>>;
|
||||
deleteReference: (id: number) => Promise<SitesmithApiResult<object>>;
|
||||
uploadRefImage: (f: File | Blob, kind: 'mockup' | 'screenshot') => Promise<SitesmithApiResult<{ image: SitesmithRefImage }>>;
|
||||
deleteRefImage: (id: string) => Promise<SitesmithApiResult<object>>;
|
||||
}
|
||||
|
||||
export type Outcome = { ok: true } | { ok: false; message: string };
|
||||
|
||||
/** Mockups, reference screenshots and "Sites I like" for one site. Never throws. */
|
||||
export function useSitesmithRefs(api: RefsApi, enabled: boolean, maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES) {
|
||||
const [references, setReferences] = useState<SitesmithReference[]>([]);
|
||||
const [images, setImages] = useState<SitesmithRefImage[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const apiRef = useRef(api);
|
||||
apiRef.current = api;
|
||||
const alive = useRef(true);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const r = await apiRef.current.listRefs();
|
||||
if (!alive.current) return;
|
||||
if (r.ok) { setReferences(r.references ?? []); setImages(r.images ?? []); setLoadError(null); }
|
||||
else setLoadError(r.message);
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (enabled && !loaded) void load(); }, [enabled, loaded, load]);
|
||||
|
||||
const uploadImage = useCallback(async (file: File, kind: 'mockup' | 'screenshot'): Promise<Outcome & { image?: SitesmithRefImage }> => {
|
||||
const bad = await validateAttachment(file, maxUploadBytes);
|
||||
if (bad) return { ok: false, message: bad };
|
||||
const r = await apiRef.current.uploadRefImage(file, kind);
|
||||
if (!r.ok) return { ok: false, message: r.message };
|
||||
if (alive.current) setImages((xs) => [...xs, r.image]);
|
||||
return { ok: true, image: r.image };
|
||||
}, [maxUploadBytes]);
|
||||
|
||||
const removeImage = useCallback(async (id: string): Promise<Outcome> => {
|
||||
const r = await apiRef.current.deleteRefImage(id);
|
||||
if (!r.ok) return { ok: false, message: r.message };
|
||||
if (alive.current) {
|
||||
setImages((xs) => xs.filter((i) => i.id !== id));
|
||||
setReferences((rs) => rs.map((x) => ({ ...x, screenshot_ids: x.screenshot_ids.filter((s) => s !== id) })));
|
||||
}
|
||||
return { ok: true };
|
||||
}, []);
|
||||
|
||||
const addRef = useCallback(async (urlLabel: string, whatILike: string, screenshotIds: string[] = []): Promise<Outcome & { reference?: SitesmithReference }> => {
|
||||
const u = normalizeUrlLabel(urlLabel);
|
||||
if (!u.ok) return u;
|
||||
if (!whatILike.trim()) return { ok: false, message: 'Say what you like about this site so Sitesmith knows what to take from it.' };
|
||||
const r = await apiRef.current.addReference({ url_label: u.value, what_i_like: whatILike.trim(), screenshot_ids: screenshotIds });
|
||||
if (!r.ok) return { ok: false, message: r.message };
|
||||
if (alive.current) setReferences((rs) => [...rs, r.reference]);
|
||||
return { ok: true, reference: r.reference };
|
||||
}, []);
|
||||
|
||||
const updateRef = useCallback(async (id: number, urlLabel: string, whatILike: string, screenshotIds?: string[]): Promise<Outcome> => {
|
||||
const u = normalizeUrlLabel(urlLabel);
|
||||
if (!u.ok) return u;
|
||||
if (!whatILike.trim()) return { ok: false, message: 'Say what you like about this site so Sitesmith knows what to take from it.' };
|
||||
const r = await apiRef.current.updateReference(id, { url_label: u.value, what_i_like: whatILike.trim(), ...(screenshotIds ? { screenshot_ids: screenshotIds } : {}) });
|
||||
if (!r.ok) return { ok: false, message: r.message };
|
||||
if (alive.current) setReferences((rs) => rs.map((x) => (x.id === id ? r.reference : x)));
|
||||
return { ok: true };
|
||||
}, []);
|
||||
|
||||
const deleteRef = useCallback(async (id: number): Promise<Outcome> => {
|
||||
const r = await apiRef.current.deleteReference(id);
|
||||
if (!r.ok) return { ok: false, message: r.message };
|
||||
if (alive.current) setReferences((rs) => rs.filter((x) => x.id !== id));
|
||||
return { ok: true };
|
||||
}, []);
|
||||
|
||||
return { references, images, loaded, loadError, reload: load, uploadImage, removeImage, addRef, updateRef, deleteRef };
|
||||
}
|
||||
@@ -4,7 +4,8 @@ export type SitesmithStatus =
|
||||
| 'OK_BONUS' | 'OK_MONTHLY'
|
||||
| 'DISABLED' | 'CAP_REACHED'
|
||||
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
|
||||
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID';
|
||||
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID'
|
||||
| 'BAD_ATTACHMENT' | 'INTERRUPTED' | 'CANCELLED' | 'JOB_IN_PROGRESS';
|
||||
|
||||
export interface SitesmithSummary {
|
||||
enabled: boolean;
|
||||
@@ -44,6 +45,6 @@ export type SitesmithResponse =
|
||||
| { type: 'patch'; ops: SitesmithPatchOp[]; message: string; }
|
||||
| { type: 'ask'; question: string; options?: string[]; };
|
||||
|
||||
export interface SendResultOk { ok: true; response: SitesmithResponse; }
|
||||
export interface SendResultOk { ok: true; response: SitesmithResponse; /** Non-blocking server messages (e.g. images skipped). */ notes?: string[]; }
|
||||
export interface SendResultErr { ok: false; status: SitesmithStatus | 'BLOCKED'; message: string; }
|
||||
export type SendResult = SendResultOk | SendResultErr;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { sanitizeCaptureHtml, CAPTURE_CSP, BLOCKED_IMAGE } from './sanitize-capture-html';
|
||||
|
||||
const san = (h: string) => sanitizeCaptureHtml(h);
|
||||
const dom = (h: string) => new DOMParser().parseFromString(h, 'text/html');
|
||||
|
||||
describe('sanitizeCaptureHtml', () => {
|
||||
const evil = `<!doctype html><html><head>
|
||||
<meta http-equiv="refresh" content="0;url=https://evil.example/"><base href="https://evil.example/">
|
||||
<link rel="import" href="https://evil.example/x.html"><link rel="preload" href="https://evil.example/a" as="script">
|
||||
<link rel="stylesheet" href="https://evil.example/x.css">
|
||||
<style>@import url(https://evil.example/i.css); body{background:url(https://evil.example/t.png)} .ok{color:red}</style>
|
||||
<script>window.__pwned=1</script></head><body onload="window.__pwned=1">
|
||||
<img src=x onerror="window.__pwned=1"><img src="assets/a.png" onclick="x()">
|
||||
<svg onload="window.__pwned=1"><script>1</script><foreignObject><div onclick="x()">f</div></foreignObject><a xlink:href="javascript:1"><rect/></a></svg>
|
||||
<a href="javascript:window.__pwned=1">j</a><a href=" JaVa	script:1">j2</a><a href="data:text/html,<script>1</script>">d</a><a href="https://ok.example/">fine</a>
|
||||
<iframe srcdoc="<script>1</script>"></iframe><iframe src="https://evil.example/"></iframe>
|
||||
<object data="https://evil.example/o"></object><embed src="https://evil.example/e">
|
||||
<form action="javascript:1"><button formaction="javascript:1">b</button></form>
|
||||
<div style="background:url(javascript:1);width:1px">s</div><div style="background-image:url('https://evil.example/p.png')">s2</div><div style="width:10px;color:blue">keep</div>
|
||||
<video src="https://evil.example/v.mp4"></video><noscript><img src=x onerror=1></noscript>
|
||||
<img srcset="https://evil.example/a.png 1x, assets/b.png 2x" src="assets/b.png">
|
||||
<p class="ok" data-animation="fade-in">Hello <b>world</b></p>
|
||||
</body></html>`;
|
||||
|
||||
test('removes every script-capable or network-reaching construct', () => {
|
||||
const out = san(evil);
|
||||
expect(out.ok).toBe(true);
|
||||
const d = dom((out as any).html);
|
||||
for (const sel of ['script', 'iframe', 'object', 'embed', 'base', 'video', 'noscript', 'foreignObject', 'foreignobject', 'link[rel=import]', 'link[rel=preload]', 'link[href*="evil"]', 'meta[http-equiv=refresh]']) {
|
||||
expect(d.querySelectorAll(sel).length, sel).toBe(0);
|
||||
}
|
||||
const all = Array.from(d.querySelectorAll('*'));
|
||||
for (const el of all) for (const a of Array.from(el.attributes)) {
|
||||
expect(/^on/i.test(a.name), `${el.tagName}.${a.name}`).toBe(false);
|
||||
expect(/^\s*(javascript|vbscript|data:text)/i.test(a.value.replace(/[\s\u0000-\u001f]/g, '')), `${el.tagName}.${a.name}=${a.value}`).toBe(false);
|
||||
expect(a.name === 'srcdoc' || a.name === 'formaction' || a.name === 'action').toBe(false);
|
||||
}
|
||||
expect((out as any).html).not.toMatch(/evil\.example/);
|
||||
expect((out as any).html).not.toMatch(/@import/i);
|
||||
});
|
||||
|
||||
test('keeps benign content, same-origin images, class/style/data-* and https links', () => {
|
||||
const out = san(evil) as any;
|
||||
const d = dom(out.html);
|
||||
expect(d.querySelector('p.ok')?.getAttribute('data-animation')).toBe('fade-in');
|
||||
expect(d.querySelector('p.ok b')?.textContent).toBe('world');
|
||||
expect(d.querySelector('img[src="assets/a.png"]')).not.toBeNull();
|
||||
expect(d.querySelector('a[href="https://ok.example/"]')).not.toBeNull();
|
||||
expect(Array.from(d.querySelectorAll('div')).some((x) => x.getAttribute('style') === 'width:10px;color:blue')).toBe(true);
|
||||
expect(d.querySelector('style')?.textContent).toContain('.ok{color:red}');
|
||||
});
|
||||
|
||||
test('same-origin URLs other than assets/<file> and the allow-list are refused (no GET to arbitrary panel paths)', () => {
|
||||
const h = '<html><body><img src="/api/site-builder.php?action=delete_asset&n=1"><img src="/api/site-builder.php?action=asset&f=a.png"><img src="x.png"><div style="background:url(/beacon?c)">a</div></body></html>';
|
||||
const open = dom((san(h, ) as any).html);
|
||||
expect(Array.from(open.querySelectorAll('img')).map((i) => i.getAttribute('src'))).toEqual([BLOCKED_IMAGE, BLOCKED_IMAGE, BLOCKED_IMAGE]);
|
||||
expect((san(h) as any).html).not.toContain('/beacon');
|
||||
const allowed = dom((sanitizeCaptureHtml(h, { allowUrls: ['/api/site-builder.php?action=asset&f=a.png'] }) as any).html);
|
||||
expect(Array.from(allowed.querySelectorAll('img')).map((i) => i.getAttribute('src'))).toEqual([BLOCKED_IMAGE, '/api/site-builder.php?action=asset&f=a.png', BLOCKED_IMAGE]);
|
||||
});
|
||||
|
||||
test('keeps font stylesheets from the two allowed hosts only', () => {
|
||||
const h = '<html><head><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css"><link rel="stylesheet" href="https://evil.example/a.css"></head><body>x</body></html>';
|
||||
const d = dom((san(h) as any).html);
|
||||
expect(d.querySelectorAll('link[rel=stylesheet]').length).toBe(2);
|
||||
});
|
||||
|
||||
test('inlines a restrictive CSP meta and exposes no other meta refresh', () => {
|
||||
const d = dom((san('<html><head></head><body>x</body></html>') as any).html);
|
||||
const csp = d.querySelector('meta[http-equiv="Content-Security-Policy"]')!;
|
||||
expect(csp.getAttribute('content')).toBe(CAPTURE_CSP);
|
||||
expect(CAPTURE_CSP).toContain("script-src 'none'");
|
||||
expect(CAPTURE_CSP).toContain("default-src 'none'");
|
||||
expect(CAPTURE_CSP).not.toMatch(/unsafe-eval|connect-src \*/);
|
||||
});
|
||||
|
||||
test('un-hides animated elements (no script will reveal them)', () => {
|
||||
const d = dom((san('<html><body><p data-animation="fade-in">a</p></body></html>') as any).html);
|
||||
expect(d.querySelector('style[data-capture]')?.textContent).toContain('[data-animation]');
|
||||
});
|
||||
|
||||
test('is a fixed point: a second pass changes nothing (mutation-XSS guard)', () => {
|
||||
const a = san(evil) as any;
|
||||
const b = san(a.html) as any;
|
||||
expect(b.html).toBe(a.html);
|
||||
});
|
||||
|
||||
test('mXSS-style payloads do not survive', () => {
|
||||
for (const p of [
|
||||
'<svg></p><style><a id="</style><img src=1 onerror=alert(1)>">',
|
||||
'<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>',
|
||||
'<noscript><p title="</noscript><img src=x onerror=alert(1)>">',
|
||||
'<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>',
|
||||
]) {
|
||||
const r = san(`<html><body>${p}</body></html>`);
|
||||
if (!r.ok) continue; // refusing is also safe
|
||||
expect(r.html).not.toMatch(/onerror/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty or non-string input is refused, not thrown', () => {
|
||||
expect(san('')).toMatchObject({ ok: false });
|
||||
expect(san(undefined as any)).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureCsp', () => {
|
||||
test('spells the origin out instead of self (Firefox does not match self in srcdoc frames)', async () => {
|
||||
const { captureCsp } = await import('./sanitize-capture-html');
|
||||
expect(captureCsp('https://panel.example')).toContain('img-src https://panel.example data:');
|
||||
expect(captureCsp('https://panel.example')).not.toContain("'self'");
|
||||
expect(captureCsp('javascript:alert(1)')).toContain("'self'");
|
||||
expect(captureCsp()).toBe(CAPTURE_CSP);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Sanitiser for the self-review capture. The page HTML contains model output and user text, so it is
|
||||
* treated as hostile: it is parsed with DOMParser (an inert document: nothing runs, nothing loads),
|
||||
* stripped to a small allow-list, re-serialised, and only then handed to a script-less iframe whose
|
||||
* own CSP (meta) forbids scripts and network access except fonts/stylesheets from two named hosts.
|
||||
*
|
||||
* Guarantees (each covered by sanitize-capture-html.test.ts and the real-browser proof
|
||||
* scripts/e2e-capture-security.mjs): no script, frame, object, embed, base, refresh, import, event
|
||||
* handler, javascript:/vbscript:/data:text URL, @import, or non-same-origin url() survives; output is
|
||||
* a fixed point of the sanitiser (mutation-XSS guard).
|
||||
*/
|
||||
|
||||
export const CAPTURE_CSP = [
|
||||
"default-src 'none'",
|
||||
"script-src 'none'",
|
||||
"img-src 'self' data:",
|
||||
"style-src 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com",
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
/** Firefox does not match 'self' inside a srcdoc iframe, so callers pass the real origin to spell it out. */
|
||||
export function captureCsp(origin?: string): string {
|
||||
return origin && /^https?:\/\/[A-Za-z0-9.:\[\]-]+$/.test(origin) ? CAPTURE_CSP.replace(/'self'/g, origin) : CAPTURE_CSP;
|
||||
}
|
||||
|
||||
const REMOVE_TAGS = new Set(['script',
|
||||
'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'base', 'noscript', 'template', 'math',
|
||||
'video', 'audio', 'source', 'track', 'portal', 'foreignobject', 'animate', 'set', 'animatetransform', 'animatemotion',
|
||||
'meta', 'title-script',
|
||||
]);
|
||||
|
||||
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'srcset', 'poster', 'data', 'background', 'ping', 'cite', 'longdesc', 'manifest', 'codebase', 'usemap', 'lowsrc', 'dynsrc']);
|
||||
const DROP_ATTRS = new Set(['srcdoc', 'http-equiv', 'is', 'formmethod', 'target', 'ping']);
|
||||
const FONT_SHEET_HOSTS = /^https:\/\/(fonts\.googleapis\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
|
||||
const FONT_FILE_HOSTS = /^https:\/\/(fonts\.gstatic\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
|
||||
|
||||
/** 1x1 grey PNG: stands in for an image the capture may not fetch (external host), keeping layout. */
|
||||
export const BLOCKED_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8d+/efwAIgQMeVxsvbAAAAABJRU5ErkJggg==';
|
||||
|
||||
const squash = (v: string) => v.replace(/[\s\u0000-\u001f\u007f-\u009f]/g, '').toLowerCase();
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Exact same-origin URLs (root-relative path + query) that may be fetched, e.g. the site's asset URLs. */
|
||||
allowUrls?: string[];
|
||||
/** Page origin, written into the frame CSP in place of 'self'. */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
interface Policy { allow: Set<string> }
|
||||
|
||||
/**
|
||||
* A reference the capture frame may load: a data: image, a published-style `assets/<file>` path, or an
|
||||
* exact URL the caller allow-listed. Every other relative or same-origin URL is refused: a GET to an
|
||||
* arbitrary panel path (with the user's cookies) is not something page markup gets to trigger.
|
||||
*/
|
||||
function isLocalRef(raw: string, pol: Policy): boolean {
|
||||
const t = raw.trim();
|
||||
const v = squash(raw);
|
||||
if (!v) return true;
|
||||
if (v.startsWith('data:image/png') || v.startsWith('data:image/jpeg') || v.startsWith('data:image/gif') || v.startsWith('data:image/webp')) return true;
|
||||
if (/^assets\/[A-Za-z0-9._%~+\/-]{1,200}$/.test(t) && !t.includes("..")) return true;
|
||||
return pol.allow.has(t);
|
||||
}
|
||||
|
||||
function safeLink(raw: string): boolean {
|
||||
const v = squash(raw);
|
||||
if (/^(javascript|vbscript|data|blob|file):/.test(v)) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:/.test(v)) return /^(https?|mailto|tel):/.test(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove @import and any url() that is not a same-origin / data-image / allowed font reference. */
|
||||
export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string {
|
||||
let out = css.replace(/@import\b[^;{}]*;?/gi, '');
|
||||
out = out.replace(/expression\s*\(|-moz-binding|behavior\s*:/gi, 'x-removed(');
|
||||
out = out.replace(/url\(\s*(["']?)([^)"']*)\1\s*\)/gi, (m, _q, u: string) => {
|
||||
const t = u.trim();
|
||||
if (isLocalRef(t, pol) || FONT_FILE_HOSTS.test(t)) return m;
|
||||
return 'none';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export type SanitizeResult = { ok: true; html: string; csp?: string } | { ok: false; reason: string };
|
||||
|
||||
function walk(root: Element, doc: Document, pol: Policy): void {
|
||||
const all = Array.from(root.querySelectorAll('*'));
|
||||
for (const el of all) {
|
||||
if (!el.isConnected && !root.contains(el)) continue;
|
||||
const tag = el.localName.toLowerCase();
|
||||
if (REMOVE_TAGS.has(tag)) { el.remove(); continue; }
|
||||
if (tag === 'link') {
|
||||
const rel = (el.getAttribute('rel') || '').toLowerCase();
|
||||
const href = (el.getAttribute('href') || '').trim();
|
||||
if (!(rel === 'stylesheet' && FONT_SHEET_HOSTS.test(href))) { el.remove(); continue; }
|
||||
for (const a of Array.from(el.attributes)) if (!['rel', 'href', 'crossorigin', 'referrerpolicy'].includes(a.name)) el.removeAttribute(a.name);
|
||||
continue;
|
||||
}
|
||||
if (tag === 'style') {
|
||||
if (el.hasAttribute('data-capture')) { el.remove(); continue; } // ours is re-added last
|
||||
const t = el.textContent || '';
|
||||
if (t.includes('<')) { el.remove(); continue; } // markup-in-CSS is only ever an mXSS probe
|
||||
el.textContent = cleanCss(t, pol);
|
||||
}
|
||||
if (tag === 'use') {
|
||||
const h = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
||||
if (h && !h.trim().startsWith('#')) { el.remove(); continue; }
|
||||
}
|
||||
for (const a of Array.from(el.attributes)) {
|
||||
const name = a.name.toLowerCase();
|
||||
if (name.startsWith('on') || DROP_ATTRS.has(name)) { el.removeAttribute(a.name); continue; }
|
||||
if (name === 'style') { el.setAttribute(a.name, cleanCss(a.value, pol)); continue; }
|
||||
if (URL_ATTRS.has(name)) {
|
||||
if (name === 'srcset') { el.removeAttribute(a.name); continue; } // one src is enough for a capture
|
||||
if (name === 'href' && (tag === 'a' || tag === 'area')) { if (!safeLink(a.value)) el.removeAttribute(a.name); continue; }
|
||||
if (name === 'href' && el.namespaceURI === 'http://www.w3.org/2000/svg') { if (!a.value.trim().startsWith('#')) el.removeAttribute(a.name); continue; }
|
||||
if (!isLocalRef(a.value, pol)) {
|
||||
if (name === 'src' && tag === 'img') { el.setAttribute('src', BLOCKED_IMAGE); el.removeAttribute('srcset'); } else el.removeAttribute(a.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void doc;
|
||||
}
|
||||
|
||||
function core(html: string, pol: Policy): string | null {
|
||||
let doc: Document;
|
||||
try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch { return null; }
|
||||
const root = doc.documentElement;
|
||||
if (!root) return null;
|
||||
walk(root, doc, pol);
|
||||
return root.outerHTML;
|
||||
}
|
||||
|
||||
/** Our own head additions, applied last to already-clean markup (trusted constants). */
|
||||
function addCaptureHead(clean: string, csp0: string): string | null {
|
||||
let doc: Document;
|
||||
try { doc = new DOMParser().parseFromString(clean, 'text/html'); } catch { return null; }
|
||||
const root = doc.documentElement;
|
||||
let head = doc.head;
|
||||
if (!head) { head = doc.createElement('head'); root.insertBefore(head, root.firstChild); }
|
||||
const csp = doc.createElement('meta');
|
||||
csp.setAttribute('http-equiv', 'Content-Security-Policy');
|
||||
csp.setAttribute('content', csp0);
|
||||
head.insertBefore(csp, head.firstChild);
|
||||
const un = doc.createElement('style');
|
||||
un.setAttribute('data-capture', '');
|
||||
// No script will ever add .animated, so reveal animated blocks and stop transitions.
|
||||
un.textContent = '[data-animation]{opacity:1!important;animation:none!important}*{transition:none!important}';
|
||||
head.appendChild(un);
|
||||
return '<!DOCTYPE html>' + root.outerHTML;
|
||||
}
|
||||
|
||||
/** Strip a model/user-derived page to something safe to render in the script-less capture frame. */
|
||||
export function sanitizeCaptureHtml(html: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
const pol: Policy = { allow: new Set((opts.allowUrls ?? []).filter((u) => /^\/[^\s"'<>]*$/.test(u))) };
|
||||
if (typeof html !== 'string' || !html.trim()) return { ok: false, reason: 'no page to capture' };
|
||||
const first = core(html, pol);
|
||||
if (first === null) return { ok: false, reason: 'page could not be parsed' };
|
||||
// Mutation-XSS guard: what we output must re-parse to itself under the same sanitiser.
|
||||
const second = core(first, pol);
|
||||
if (second === null || second !== first) return { ok: false, reason: 'page markup was not stable under sanitising' };
|
||||
const out = addCaptureHead(first, captureCsp(opts.origin));
|
||||
return out === null ? { ok: false, reason: 'page could not be parsed' } : { ok: true, html: out, csp: captureCsp(opts.origin) };
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* Client-side page capture for the Sitesmith self-review loop.
|
||||
*
|
||||
* Renders published-style HTML into a hidden same-origin iframe whose sandbox
|
||||
* allows NO scripts (customer head code and model output can never run in the
|
||||
* panel origin; `allow-same-origin` is only there so html-to-image can read the
|
||||
* DOM) and rasterises it to a JPEG with html-to-image (MIT, zero dependencies).
|
||||
* Renders published-style HTML into a hidden iframe and rasterises it to a JPEG with
|
||||
* html-to-image (MIT, zero dependencies). The HTML is hostile input (model output, user
|
||||
* text, customer head code): it is first run through sanitizeCaptureHtml (inert DOMParser
|
||||
* walk: no script/frame/object/handlers/javascript: URLs/@import/foreign url(); fixed-point
|
||||
* checked) and the frame itself has a sandbox WITHOUT allow-scripts plus a CSP that forbids
|
||||
* scripts and all network access except fonts/stylesheets from two hosts. `allow-same-origin`
|
||||
* is kept only so the parent can read the (already sanitised, script-less) frame document.
|
||||
*
|
||||
* Never throws: every failure is a `{ ok: false, reason }` so the caller can
|
||||
* skip the review with a non-blocking note.
|
||||
*/
|
||||
import { toJpeg } from 'html-to-image';
|
||||
import { sanitizeCaptureHtml } from './sanitize-capture-html';
|
||||
|
||||
export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
|
||||
|
||||
@@ -26,6 +30,8 @@ export interface CaptureOptions {
|
||||
maxBytes?: number;
|
||||
/** Add the Font Awesome stylesheet when the HTML has none (Preview head may lack it). Default true. */
|
||||
injectFontAwesome?: boolean;
|
||||
/** Same-origin URLs (root-relative) the page may load, e.g. the site's asset proxy URLs. */
|
||||
allowUrls?: string[];
|
||||
/** Extra settle time after load for fonts/images (default 600 ms). */
|
||||
settleMs?: number;
|
||||
}
|
||||
@@ -55,8 +61,15 @@ export function resolveAssetUrls(html: string, urls: Record<string, string>): st
|
||||
let out = html;
|
||||
for (const [rel, url] of Object.entries(urls)) {
|
||||
if (!/^assets\/[A-Za-z0-9._%~+-]{1,200}$/.test(rel)) continue;
|
||||
if (!/^(https?:\/\/|\/)[^\s"'<>]*$/.test(url)) continue;
|
||||
out = out.split(rel).join(url);
|
||||
// Same-origin only, written as a root-relative path (the sanitiser refuses absolute URLs).
|
||||
let local = url;
|
||||
try {
|
||||
const u = new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/');
|
||||
if (typeof location !== 'undefined' && u.origin !== location.origin) continue;
|
||||
local = u.pathname + u.search;
|
||||
} catch { continue; }
|
||||
if (!/^\/[^\s"'<>]*$/.test(local)) continue;
|
||||
out = out.split(rel).join(local);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -148,7 +161,10 @@ export async function captureHtml(html: string, opts: CaptureOptions = {}): Prom
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
frame.addEventListener('load', () => resolve(), { once: true });
|
||||
frame.addEventListener('error', () => reject(new Error('frame failed to load')), { once: true });
|
||||
frame.srcdoc = prepareCaptureHtml(html, opts.injectFontAwesome !== false);
|
||||
const clean = sanitizeCaptureHtml(prepareCaptureHtml(html, opts.injectFontAwesome !== false), { allowUrls: opts.allowUrls, origin: typeof location !== 'undefined' ? location.origin : undefined });
|
||||
if (!clean.ok) { reject(new Error(`unsafe page not captured (${clean.reason})`)); return; }
|
||||
frame.srcdoc = clean.html;
|
||||
frame.setAttribute('csp', clean.csp ?? '');
|
||||
document.body.appendChild(frame);
|
||||
});
|
||||
const doc = frame.contentDocument;
|
||||
@@ -186,7 +202,7 @@ export async function captureHtml(html: string, opts: CaptureOptions = {}): Prom
|
||||
try {
|
||||
return await Promise.race([work, timeout]);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : 'capture failed' };
|
||||
return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : (typeof Event !== 'undefined' && e instanceof Event ? `an image or resource failed to render (${e.type})` : 'capture failed') };
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
frame.remove();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Client-side validation for mockup / reference-screenshot attachments (the server re-checks from the bytes). */
|
||||
|
||||
export const ATTACH_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export type SniffedType = (typeof ATTACH_TYPES)[number] | null;
|
||||
|
||||
/** Decide the type from the file's first bytes, never from its name or declared MIME. */
|
||||
export async function sniffImageType(file: Blob): Promise<SniffedType> {
|
||||
let b: Uint8Array;
|
||||
try { b = new Uint8Array(await file.slice(0, 12).arrayBuffer()); } catch { return null; }
|
||||
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
|
||||
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
|
||||
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif';
|
||||
if (b.length >= 12 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') return 'image/webp';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/** null when the file is acceptable; otherwise a plain-language reason. */
|
||||
export async function validateAttachment(file: File, maxBytes = DEFAULT_MAX_UPLOAD_BYTES): Promise<string | null> {
|
||||
const label = file.name ? `"${file.name.slice(0, 60)}"` : 'That file';
|
||||
if (file.size === 0) return `${label} is empty.`;
|
||||
if (file.size > maxBytes) return `${label} is ${formatBytes(file.size)}; the limit is ${formatBytes(maxBytes)}. Use a smaller image.`;
|
||||
const t = await sniffImageType(file);
|
||||
if (!t) return `${label} is not a JPEG, PNG, WebP or GIF image (SVG, PDF and other formats are not accepted).`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Only http(s) URLs (or a bare domain) are accepted as a "site I like" label; it is never fetched. */
|
||||
export function normalizeUrlLabel(raw: string): { ok: true; value: string } | { ok: false; message: string } {
|
||||
const v = raw.trim();
|
||||
if (!v) return { ok: false, message: 'Enter the site address, for example example.com.' };
|
||||
if (v.length > 300) return { ok: false, message: 'That address is too long (300 characters at most).' };
|
||||
if (/[\u0000-\u001f<>"]/.test(v)) return { ok: false, message: 'The address contains characters that are not allowed.' };
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(v) && !/^https?:\/\//i.test(v)) {
|
||||
return { ok: false, message: 'Only http:// or https:// addresses are accepted.' };
|
||||
}
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The site design block sent with every Sitesmith request, in the shape the backend prompt builder
|
||||
* (sitesmith_design_block in libs/sitesmith-prompts.php) reads: SiteDesign-named keys plus
|
||||
* `brandColors: [{name?, hex}]` (up to 6, from the colour-system swatches flagged `brand`).
|
||||
* The backend re-validates every value; this only keeps obviously unusable values out.
|
||||
*/
|
||||
import type { SiteDesign } from '../state/SiteDesignContext';
|
||||
|
||||
export interface DesignSwatch { label?: string; hex?: string; brand?: boolean }
|
||||
|
||||
const KEYS = [
|
||||
'primaryColor', 'secondaryColor', 'accentColor', 'backgroundColor', 'textColor', 'mutedTextColor',
|
||||
'borderColor', 'headingFont', 'bodyFont', 'borderRadius', 'buttonRadius',
|
||||
] as const;
|
||||
|
||||
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
export function buildDesignPayload(design: SiteDesign & { swatches?: DesignSwatch[] }): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of KEYS) {
|
||||
const v = (design as unknown as Record<string, unknown>)[k];
|
||||
if (typeof v !== 'string' || !v.trim()) continue;
|
||||
// Font stacks: the backend accepts letters, digits, space, comma, dash and underscore only.
|
||||
out[k] = k.endsWith('Font') ? v.replace(/["']/g, '').trim() : v.trim();
|
||||
}
|
||||
const sw = (design as { swatches?: unknown }).swatches;
|
||||
if (Array.isArray(sw)) {
|
||||
const brand = sw
|
||||
.filter((s): s is DesignSwatch => !!s && typeof s === 'object' && (s as DesignSwatch).brand === true
|
||||
&& typeof (s as DesignSwatch).hex === 'string' && HEX.test((s as DesignSwatch).hex!.trim()))
|
||||
.slice(0, 6)
|
||||
.map((s) => {
|
||||
const name = typeof s.label === 'string' ? s.label.replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 40) : '';
|
||||
return name ? { name, hex: s.hex!.trim().toLowerCase() } : { hex: s.hex!.trim().toLowerCase() };
|
||||
});
|
||||
if (brand.length) out.brandColors = brand;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user