diff --git a/craft/scripts/e2e-capture-security-entry.ts b/craft/scripts/e2e-capture-security-entry.ts new file mode 100644 index 0000000..ba83388 --- /dev/null +++ b/craft/scripts/e2e-capture-security-entry.ts @@ -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((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('', ``); + document.body.appendChild(f); +}); diff --git a/craft/scripts/e2e-capture-security.mjs b/craft/scripts/e2e-capture-security.mjs new file mode 100644 index 0000000..11fa801 --- /dev/null +++ b/craft/scripts/e2e-capture-security.mjs @@ -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 = ` + + + + + +

Hostile but readable

+ + +js link
+ + +
x
+
s
+

Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}

+`; +const BENIGN = ` +

Benign page

${'Hello world. '.repeat(30)}

`; + +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('hosthost'); } + 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); diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 5542b94..4dc5b51 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -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; + /** 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', diff --git a/craft/src/panels/sitesmith/AssetAwareness.tsx b/craft/src/panels/sitesmith/AssetAwareness.tsx new file mode 100644 index 0000000..182d834 --- /dev/null +++ b/craft/src/panels/sitesmith/AssetAwareness.tsx @@ -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; + 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 = ({ assets, loaded, error, included, onToggle, onAll, thumbUrl, visionAvailable, disabled }) => ( +
+

+ 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.'} +

+ {error &&
{error}
} + {!loaded && !error &&

Loading your images…

} + {loaded && !error && assets.length === 0 &&

You have not uploaded any images yet. Add some in the Assets tab and Sitesmith will be able to use them.

} + {assets.length > 0 && ( + <> +
+ + + {included.size} of {assets.length} included +
+
    + {assets.map((a) => ( +
  • + onToggle(a.filename)} aria-label={`Let Sitesmith use ${a.filename}`} /> + +
    +
    {a.filename}{a.width && a.height ? · {a.width}×{a.height} : null}
    +
    {a.caption || a.alt || 'No description yet'}
    +
    +
  • + ))} +
+ + )} +
+); diff --git a/craft/src/panels/sitesmith/AttachmentTray.tsx b/craft/src/panels/sitesmith/AttachmentTray.tsx new file mode 100644 index 0000000..c25689a --- /dev/null +++ b/craft/src/panels/sitesmith/AttachmentTray.tsx @@ -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 = ({ + images, selected, onSelectedChange, maxAttach, referenceImageCount, imageUrl, onUpload, onRemove, unavailableReason, disabled, +}) => { + const input = useRef(null); + const [drag, setDrag] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(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 ( +
+

+ 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. +

+ {unavailableReason &&
{unavailableReason}
} +
{ 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, + }} + > + + or drop it here (JPEG, PNG, WebP or GIF, up to 8 MB) + { void addFiles(e.target.files); e.target.value = ''; }} + /> +
+ {err &&
{err}
} + {mockups.length > 0 && ( +
    + {mockups.map((im) => { + const on = selected.includes(im.id); + return ( +
  • + +
    {im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}
    + +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/craft/src/panels/sitesmith/Section.tsx b/craft/src/panels/sitesmith/Section.tsx new file mode 100644 index 0000000..02687f2 --- /dev/null +++ b/craft/src/panels/sitesmith/Section.tsx @@ -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 }) => ( +
+ + {open &&
{children}
} +
+); + +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' }; diff --git a/craft/src/panels/sitesmith/SitesILikePanel.tsx b/craft/src/panels/sitesmith/SitesILikePanel.tsx new file mode 100644 index 0000000..06ffcfb --- /dev/null +++ b/craft/src/panels/sitesmith/SitesILikePanel.tsx @@ -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; + updateRef: (id: number, url: string, likes: string, shots: string[]) => Promise; + deleteRef: (id: number) => Promise; + uploadShot: (f: File) => Promise; + removeImage: (id: string) => Promise; + 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 = ({ + references, images, selected, onSelectedChange, imageUrl, loadError, addRef, updateRef, deleteRef, uploadShot, removeImage, + maxRefs, imagesUnavailableReason, disabled, +}) => { + const [form, setForm] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmDel, setConfirmDel] = useState(null); + const fileRef = useRef(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 ( +
+

+ List websites whose look you admire and say what you like about each. The address is only a label for you: + Sitesmith does not open, visit or fetch it. It works from your description and any screenshots you attach yourself. +

+ {loadError &&
{loadError}
} + {err && !form &&
{err}
} + + {references.length === 0 && !form &&

No sites yet.

} +
    + {references.map((r) => ( +
  • +
    + toggle(r.id)} aria-label={`Use ${r.url_label} for the next request`} style={{ marginTop: 3 }} /> +
    +
    {r.url_label}
    +
    {r.what_i_like}
    + {r.screenshot_ids.length > 0 && ( +
    + {r.screenshot_ids.map((sid) => ( + {`Screenshot + ))} +
    + )} +
    + + {confirmDel === r.id + ? + : } +
    +
  • + ))} +
+ + {form ? ( +
+ + setForm({ ...form, url: e.target.value })} /> + +