diff --git a/.changeset/session-html-export.md b/.changeset/session-html-export.md new file mode 100644 index 0000000..75c234c --- /dev/null +++ b/.changeset/session-html-export.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add session HTML export. `GET /api/sessions/:id/export` and the new `sideshow export` command render a whole session into one self-contained, shareable HTML file styled like the viewer's card column. Every surface that becomes HTML is embedded as a sandboxed `srcdoc` iframe using the exact `/s/:id` renderers, so the isolation rule holds inside the saved file; image surfaces are inlined as data URIs (allowlisted raster types only, capped at 32 MB of image bytes per export — further images degrade to a note), and sessions over 4 MB of surface text are rejected with a 413. Supports `?theme=`/`?mode=` pinning and `?download=1` for an attachment download. diff --git a/bin/sideshow.js b/bin/sideshow.js index 55083da..cd9e326 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -129,6 +129,9 @@ usage: --surface is a deprecated alias) --author defaults to agent name sideshow list [--session |--all] list posts + sideshow export [--session ] [--out ] [--theme ] [--mode ] + export a session as one self-contained HTML file + (default: auto session, never created; stdout) sideshow show show a single post (surfaces, indexes, ids, version, history) sideshow sessions list sessions sideshow demo seed two example sessions to explore the viewer @@ -155,13 +158,15 @@ function fail(msg) { process.exit(1); } -async function api(path, init = {}) { +// Raw fetch with the CLI's standard failure handling — unreachable server and +// non-2xx (JSON error body) both exit via fail(). Callers own the body read: +// api() parses JSON; export reads HTML text; uploads send raw bytes. +async function rawFetch(path, init = {}) { let res; try { res = await fetch(`${BASE}${path}`, { ...init, headers: { - "content-type": "application/json", ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), ...init.headers, }, @@ -169,9 +174,19 @@ async function api(path, init = {}) { } catch { fail(`server not reachable at ${BASE} — start it with: sideshow serve`); } - const body = await res.json().catch(() => ({})); - if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`); - return body; + if (!res.ok) { + const body = await res.json().catch(() => ({})); + fail(body.error ?? `${res.status} ${res.statusText}`); + } + return res; +} + +async function api(path, init = {}) { + const res = await rawFetch(path, { + ...init, + headers: { "content-type": "application/json", ...init.headers }, + }); + return res.json().catch(() => ({})); } // Like api(), but throws instead of exiting the process — for callers that must @@ -452,22 +467,12 @@ async function uploadFile(file, { session, kind } = {}) { params.set("filename", file.split(/[\\/]/).pop() ?? "upload"); if (session) params.set("session", session); if (kind) params.set("kind", kind); - let res; - try { - res = await fetch(`${BASE}/api/assets?${params}`, { - method: "POST", - headers: { - "content-type": contentTypeFor(file), - ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), - }, - body: bytes, - }); - } catch { - fail(`server not reachable at ${BASE} — start it with: sideshow serve`); - } - const body = await res.json().catch(() => ({})); - if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`); - return body; + const res = await rawFetch(`/api/assets?${params}`, { + method: "POST", + headers: { "content-type": contentTypeFor(file) }, + body: bytes, + }); + return res.json().catch(() => ({})); } // Normalize repeated/comma-joined --kit flags into a deduped id list (or @@ -1541,6 +1546,37 @@ const commands = { out(await api(`/api/posts/${id}`)); }, + // Export a whole session as one self-contained HTML file (every surface + // embedded as a sandboxed srcdoc iframe). resolveSession WITHOUT create — an + // export must never mint a session — and rawFetch (not api(), which + // JSON-parses) since the body is HTML. + async export() { + const { values: flags } = parse({ + options: { + session: { type: "string" }, + out: { type: "string" }, + theme: { type: "string" }, + mode: { type: "string" }, + }, + }); + const session = await resolveSession(flags); + if (!session) { + fail("no session to export — pass --session (export never creates a session)"); + } + const q = new URLSearchParams(); + if (flags.theme) q.set("theme", flags.theme); + if (flags.mode) q.set("mode", flags.mode); + const qs = q.toString(); + const res = await rawFetch(`/api/sessions/${session}/export${qs ? `?${qs}` : ""}`); + const html = await res.text(); + if (flags.out) { + writeFileSync(flags.out, html); + console.log(`Wrote ${flags.out} (${html.length} bytes)`); + } else { + process.stdout.write(html); + } + }, + async sessions() { parse(); out(await api("/api/sessions")); diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts new file mode 100644 index 0000000..40c51ed --- /dev/null +++ b/e2e/export.spec.ts @@ -0,0 +1,85 @@ +import { expect, publishParts, test } from "./fixtures.ts"; + +// The session export is a trusted shell document that embeds every surface as a +// sandboxed srcdoc iframe. This spec proves the healthy path end-to-end on real +// Chromium and WebKit: the srcdoc frames lay out and the bridge sizes them (the +// reason the retry + staggered timers exist), and a script inside an html +// surface still can't reach the shell — the opaque origin holds even without a +// /s/:id URL. (The Chrome 149 field trial itself can't be reproduced in +// Playwright per commit 5e3f292; the retry ships on the in-repo precedent.) + +const MD = [ + "## Exported plan", + "", + "Prose that wraps across several lines so the rendered markdown is clearly", + "taller than the 24px minimum frame height once the bridge measures it.", + "", + "- one", + "- two", + "- three", +].join("\n"); + +// A probe that tries to read the shell document across the sandbox boundary. +// The frame is sandboxed WITHOUT allow-same-origin, so `parent.document` throws +// a SecurityError — the frame is at an opaque origin. It self-reports into its +// own DOM (the only channel it has). +const PROBE = `
running
+`; + +test("a session exports to a self-contained HTML shell whose frames lay out and stay isolated", async ({ + page, + server, +}) => { + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + + const first = await publishParts(server.url, { + title: "Probe card", + agent: "e2e", + parts: [{ kind: "html", html: PROBE }], + }); + await publishParts(server.url, { + title: "Markdown card", + agent: "e2e", + session: first.sessionId, + parts: [{ kind: "markdown", markdown: MD }], + }); + + await page.goto(`${server.url}/api/sessions/${first.sessionId}/export`); + + // Two cards, chronological: the probe first, the markdown second. + await expect(page.locator(".ss-card")).toHaveCount(2); + + // Every embedded surface is sandboxed with no allow-same-origin. + await expect(page.locator("iframe.ss-frame:not(.mdframe)")).toHaveAttribute( + "sandbox", + "allow-scripts", + ); + await expect(page.locator("iframe.mdframe")).toHaveAttribute("sandbox", "allow-scripts"); + + // (a) the markdown frame's bridge reports height, so it grows past the min. + await expect + .poll(async () => (await page.locator("iframe.mdframe").boundingBox())?.height ?? 0, { + timeout: 10_000, + }) + .toBeGreaterThan(60); + + // (b) the html surface's script cannot reach the shell — opaque origin holds. + const probe = page.frameLocator("iframe.ss-frame:not(.mdframe)").locator("#r"); + await expect(probe).toHaveText("blocked", { timeout: 10_000 }); + await expect(probe).not.toContainText("LEAKED"); + + // (c) no CSP / security console errors from the shell or its frames. + expect(consoleErrors.filter((e) => /content security policy|security|refused/i.test(e))).toEqual( + [], + ); +}); diff --git a/guide/AGENT_HOWTO.md b/guide/AGENT_HOWTO.md index 9fe8e08..55576c9 100644 --- a/guide/AGENT_HOWTO.md +++ b/guide/AGENT_HOWTO.md @@ -81,6 +81,17 @@ Feedback reaches you four ways — prefer them in this order: Comments attach to a post (`postId`); behavior is otherwise unchanged. When comments arrive, acknowledge briefly with `sideshow comment "..." --post ` when useful; do substantial changes as post updates, then re-arm the watcher or continue checkpoint-draining. +## Exporting a session + +Export a whole session as one self-contained HTML file the user can save and share — every post rendered into the viewer's card column, each surface still sandboxed: + +```sh +sideshow export --session --out session.html # or omit --out for stdout +curl -s "${SIDESHOW_URL:-http://localhost:8228}/api/sessions//export" > session.html +``` + +Add `?download=1` to the URL for an attachment download, `?theme=` / `?mode=light|dark` to pin the look (default follows the reader's OS). The file opens straight from disk with no server; three things still need the network when present — `/a/:id` assets referenced _inside_ agent-authored html/markdown (image _surfaces_ are inlined, up to 32 MB of image bytes per export; further images degrade to a note), and Mermaid diagrams (CDN). A session over 4 MB of surface text is rejected with a 413 instead of producing an unloadable file. There is no MCP tool for this — megabytes of HTML don't belong in a tool result, so MCP agents use the HTTP route. + ## Remote surfaces A deployed sideshow needs `SIDESHOW_URL` and `SIDESHOW_TOKEN` set in your environment; the CLI and MCP server send the token automatically. For raw curl, add `-H "Authorization: Bearer $SIDESHOW_TOKEN"`. diff --git a/package-lock.json b/package-lock.json index 17b98f7..e141fc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3473,21 +3473,34 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -4061,20 +4074,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4526,9 +4525,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4745,9 +4744,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -5950,16 +5949,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -6194,43 +6183,17 @@ } }, "node_modules/read-yaml-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", - "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-2.1.0.tgz", + "integrity": "sha512-UkRNRIwnhG+y7hpqnycCL/xbTk7+ia9VuVTC0S+zVbwd65DI9eUpRMfsWIGrCWxTU/mi+JW8cHQCrv+zfCbEPQ==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.5", - "js-yaml": "^3.6.1", - "pify": "^4.0.1", - "strip-bom": "^3.0.0" + "js-yaml": "^4.0.0", + "strip-bom": "^4.0.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/read-yaml-file/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=10.13" } }, "node_modules/regex": { @@ -6751,13 +6714,6 @@ "signal-exit": "^4.0.1" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6825,13 +6781,13 @@ } }, "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/supports-color": { diff --git a/package.json b/package.json index 991083f..df9e44b 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ }, "overrides": { "esbuild": "^0.28.1", + "read-yaml-file": "^2.1.0", "undici": "^7.28.0", "ws": "^8.21.0" }, diff --git a/server/app.ts b/server/app.ts index 0dae938..fd3a87d 100644 --- a/server/app.ts +++ b/server/app.ts @@ -15,24 +15,17 @@ import { import { EventBus, type FeedEvent } from "./events.ts"; import { kitSummaries } from "./kits.ts"; import { registerMcp } from "./mcpHttp.ts"; -import { - escapeHtml, - renderHtmlPage, - renderMermaidPage, - renderSandboxedPart, -} from "./surfacePage.ts"; -import { renderCode, renderDiff, renderMarkdown, renderTerminal } from "./richRender.ts"; -import { DEFAULT_THEME_ID, themeById, themeOptions } from "./themes.ts"; +import { escapeHtml, renderSurfaceDocument } from "./surfacePage.ts"; +import { MAX_EXPORT_SURFACE_BYTES, renderSessionExport } from "./exportPage.ts"; +import { DEFAULT_THEME_ID, type Mode, themeOptions } from "./themes.ts"; import { type Asset, type AssetKind, - type CodeSurface, type Comment, type CommentAnchor, - type DiffSurface, htmlSurface, isSandboxedSurfaceKind, - type MarkdownSurface, + INLINE_IMAGE_TYPES, MAX_ASSET_BYTES, surfacesByteLength, type Session, @@ -40,7 +33,6 @@ import { type Post, type Surface, SURFACE_CONTENT_FIELDS, - type TerminalSurface, type TraceStep, } from "./types.ts"; import { validateSurfaces } from "./postSurfaces.ts"; @@ -89,18 +81,12 @@ const MAX_TITLE = 500; // exercise the cap cheaply. const DEFAULT_MAX_HOLD_CONNECTIONS = 32; -// Asset serving policy: only raster images are served inline; everything else -// (incl. svg, json, text, the octet-stream catch-all) is an attachment, so a -// top-level open of /a/:id can never execute an uploaded document as a live -// same-origin script. /fetch ignore Content-Disposition, so embedding and -// inline trace rendering keep working regardless. -const INLINE_IMAGE_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/avif", -]); +// Asset serving policy: only raster images (INLINE_IMAGE_TYPES, types.ts) are +// served inline; everything else (incl. svg, json, text, the octet-stream +// catch-all) is an attachment, so a top-level open of /a/:id can never execute +// an uploaded document as a live same-origin script. /fetch ignore +// Content-Disposition, so embedding and inline trace rendering keep working +// regardless. const ATTACH_SAFE_TYPES = new Set([ "image/svg+xml", "application/json", @@ -1077,6 +1063,81 @@ export function createApp({ app.get("/api/sessions/:id/posts", listSessionPosts); app.get("/api/sessions/:id/snippets", listSessionPosts); // legacy alias + // Resolve the theme/scheme a rendered document should bake in: an explicit + // ?theme= (the viewer keys iframe srcs by it so a switch reloads the frame) + // wins over the persisted workspace theme. ?mode= must be an explicit scheme + // — the viewer passes the one it resolved so the frame can't diverge from the + // chrome; absent/invalid → follow the OS. + const resolveThemeMode = async (c: any): Promise<{ themeId: string; mode?: Mode }> => { + const themeId = c.req.query("theme") ?? (await store.getSetting("theme")) ?? DEFAULT_THEME_ID; + const modeParam = c.req.query("mode"); + return { + themeId, + mode: modeParam === "light" || modeParam === "dark" ? modeParam : undefined, + }; + }; + + // Export a whole session as one self-contained, shareable HTML file. Every + // surface is rendered with the exact /s/:id renderers and embedded as a + // sandboxed srcdoc iframe (see exportPage.ts) so the core isolation rule holds + // inside the saved file. Under /api/sessions/, so auth + publicRead: "session" + // gating (isPublicReadAllowed) come free. No load-restricting CSP on the + // response: srcdoc children inherit the parent's CSP, so a policy here would + // break the CDN loads html/mermaid frames need; frame-ancestors is a header + // (doesn't restrict subresource loads, and is meaningless in the saved file). + app.get("/api/sessions/:id/export", async (c) => { + const session = await store.getSession(c.req.param("id")); + if (!session) return c.json({ error: "session not found" }, 404); + const posts = await store.listPosts(session.id); // createdAt ASC → chronological + // Reject oversized sessions before any rendering: per-post text is capped + // (MAX_SURFACE_BYTES) but post count isn't, so without an aggregate bound + // one export could be made to render unbounded input — unauthenticated on a + // publicRead workspace. The check is a cheap string-length sum. + let inputBytes = 0; + for (const post of posts) inputBytes += surfacesByteLength(post.surfaces); + if (inputBytes > MAX_EXPORT_SURFACE_BYTES) { + return c.json( + { error: `session too large to export (over ${MAX_EXPORT_SURFACE_BYTES} surface bytes)` }, + 413, + ); + } + // One comments query for the whole session, grouped by post — per-post + // listComments calls were N+1 (and JsonFileStore rescans every comment per + // call, so quadratic). Session-level comments (postId null) are excluded + // in v1 (a documented no-op). + const byPost = new Map(); + for (const comment of await store.listComments({ sessionId: session.id })) { + if (!comment.postId) continue; + const list = byPost.get(comment.postId); + if (list) list.push(comment); + else byPost.set(comment.postId, [comment]); + } + const items = posts.map((post) => ({ post, comments: byPost.get(post.id) ?? [] })); + const { themeId, mode } = await resolveThemeMode(c); + const origin = new URL(c.req.url).origin; + const html = await renderSessionExport({ + session, + items, + origin, + themeId, + mode, + generatedAt: new Date().toISOString(), + getAsset: (id) => store.getAsset(id), + }); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Security-Policy", "frame-ancestors 'self'"); + c.header("Cache-Control", "private, no-cache"); + if (c.req.query("download") === "1") { + const slug = + (session.title || session.id) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") || session.id; + c.header("Content-Disposition", `attachment; filename="sideshow-${slug}.html"`); + } + return c.html(html); + }); + // --- session trace --- app.get("/api/sessions/:id/trace", async (c) => { @@ -1520,15 +1581,7 @@ export function createApp({ // allow-scripts so the bridge still runs, but no allow-same-origin, so agent // code can never touch the workspace origin. Mirrors the iframe's sandbox flags. c.header("Content-Security-Policy", "sandbox allow-scripts"); - // Theme: an explicit ?theme= (the viewer keys iframe srcs by it so a switch - // reloads the frame) wins; otherwise the persisted workspace theme; else default. - const themeId = c.req.query("theme") ?? (await store.getSetting("theme")) ?? DEFAULT_THEME_ID; - const theme = themeById(themeId); - // Scheme: the viewer passes the light/dark mode it resolved so the iframe is - // pinned to it rather than re-deriving from the OS (which can diverge from - // the chrome across the frame boundary). Absent/invalid → follow the OS. - const modeParam = c.req.query("mode"); - const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined; + const { themeId, mode } = await resolveThemeMode(c); const origin = new URL(c.req.url).origin; // Cache the finished document. The key pins everything the output depends @@ -1540,35 +1593,9 @@ export function createApp({ if (immutable) c.header("Cache-Control", "public, max-age=31536000, immutable"); else c.header("Cache-Control", "private, no-cache"); - const doc = await cachedRender(cacheKey, async () => { - if (surface.kind === "html") { - return renderHtmlPage({ - title, - html: surface.html, - origin, - theme, - mode, - kits: surface.kits, - }); - } - if (surface.kind === "mermaid") { - return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode }); - } - const rendered = - surface.kind === "markdown" - ? await renderMarkdown(surface as MarkdownSurface, { theme: themeId, mode }) - : surface.kind === "code" - ? await renderCode(surface as CodeSurface, { theme: themeId, mode }) - : surface.kind === "terminal" - ? renderTerminal(surface as TerminalSurface) - : await renderDiff(surface as DiffSurface, { theme: themeId, mode }).catch((e) => ({ - body: `
Couldn’t render diff — ${escapeHtml( - e instanceof Error ? e.message : "render error", - )}
`, - css: `.rich-error{color:var(--danger);font:13px/1.5 ui-monospace,monospace;padding:8px 12px;}`, - })); - return renderSandboxedPart({ body: rendered.body, css: rendered.css, origin, theme, mode }); - }); + const doc = await cachedRender(cacheKey, () => + renderSurfaceDocument(surface, { title, origin, themeId, mode }), + ); return c.html(doc); }; app.get("/s/:id", renderPostPage); // legacy alias diff --git a/server/base64.ts b/server/base64.ts index fee7755..6a84944 100644 --- a/server/base64.ts +++ b/server/base64.ts @@ -12,3 +12,17 @@ export function decodeBase64(b64: string): Uint8Array { for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } + +// bytes -> base64, runtime-agnostic (btoa is a global in Node and Workers, same +// as the btoa server/types.ts already relies on for id generation). Chunked +// String.fromCharCode: spreading a whole multi-megabyte asset in one call blows +// the argument-count limit (RangeError), so build the binary string in 32 KiB +// slices. No Buffer — that's Node-only and would break the Worker DO. +export function encodeBase64(bytes: Uint8Array): string { + const CHUNK = 0x8000; + let bin = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(bin); +} diff --git a/server/exportPage.ts b/server/exportPage.ts new file mode 100644 index 0000000..e1541cc --- /dev/null +++ b/server/exportPage.ts @@ -0,0 +1,365 @@ +// Session HTML export — render a whole session into ONE self-contained, +// shareable HTML document. Runtime-agnostic (no `node:` imports, no DOM +// globals) so the Worker DO serves it too; enforced via the app.ts import chain +// under tsconfig.workers.json. +// +// The core isolation rule holds INSIDE the exported file, opened from disk on a +// teammate's machine where no server serves /s/:id: every surface that becomes +// HTML is rendered with the EXACT same sandboxed-document dispatch /s/:id +// serves (renderSurfaceDocument), then embedded as +// ``; +} + +// Render one surface to the HTML fragment that sits inside a card body. Sandboxed +// kinds become srcdoc iframes of the same documents /s/:id serves; native kinds +// (image/json/trace) become escaped, data-only markup. `postTitle` names the +// html-surface document (matches renderPostPage passing post.title). +async function exportSurface( + surface: Surface, + ctx: ExportContext, + postTitle: string, +): Promise { + if (isSandboxedSurfaceKind(surface.kind)) { + const doc = await renderSurfaceDocument(surface, { + title: postTitle, + origin: ctx.origin, + themeId: ctx.themeId, + mode: ctx.mode, + // srcdoc's base is about:srcdoc, so an html surface's relative /a/:id + // refs would break; pin the base to the origin (works only for readers + // with access to it — documented portability boundary). Only html + // surfaces consume this; the rich kinds pin themselves. + baseHref: `${ctx.origin}/`, + }); + return frame(doc, SURFACE_FRAME_CLASSES[surface.kind]); + } + + if (surface.kind === "image") { + const note = (msg: string) => + `

Image asset ${escapeHtml(surface.assetId)} ${msg}

`; + let inlined = ctx.inlinedAssets.get(surface.assetId); + if (!inlined) { + const asset = await ctx.getAsset(surface.assetId); + if (!asset) return note("is no longer available."); + // contentType is upload-controlled and this lives in the trusted + // shell, so only the raster allowlist may reach the data: URI — a crafted + // type could otherwise smuggle markup into the attribute or a scriptable + // document (svg) into the shell. Escaped like every other attribute even + // though the allowlisted URI is inert, so safety doesn't hinge on the list. + if (!INLINE_IMAGE_TYPES.has(asset.contentType)) { + return note("has a non-image content type and was omitted."); + } + // Budget-checked BEFORE encoding: an over-budget asset must never be + // encoded or memoized, or N distinct oversized assets could accumulate + // encodings the budget was supposed to bound. + if (asset.data.byteLength > ctx.assetBytesRemaining) { + return note("omitted — this export reached its inline-image size limit."); + } + inlined = { + byteLength: asset.data.byteLength, + dataUri: `data:${asset.contentType};base64,${encodeBase64(asset.data)}`, + }; + ctx.inlinedAssets.set(surface.assetId, inlined); + } else if (inlined.byteLength > ctx.assetBytesRemaining) { + return note("omitted — this export reached its inline-image size limit."); + } + ctx.assetBytesRemaining -= inlined.byteLength; + const alt = escapeHtml(surface.alt ?? ""); + const caption = surface.caption + ? `
${escapeHtml(surface.caption)}
` + : ""; + return `
${alt}${caption}
`; + } + + if (surface.kind === "json") { + // Safe path (b): data escaped by construction. `?? "null"` guards the JS + // `undefined` JSON.stringify can return, so escapeHtml never sees undefined. + const text = JSON.stringify(surface.data, null, 2) ?? "null"; + return `
${escapeHtml(text)}
`; + } + + // trace: experimental kind stays out of the product surface (CLAUDE.md). + // Honest-but-omitted beats silently dropping it. + return `

Trace surface omitted from export.

`; +} + +interface RenderedPost { + post: Post; + comments: Comment[]; + surfaces: string[]; +} + +// The shell script: the postMessage bridge (resize / open-link / copy) plus the +// per-iframe Chrome-149 srcdoc re-parse retry ported from commit 5e3f292. No +// agent behind a static file, so send-prompt/switch-session are dropped. +const SHELL_JS = ` +(function () { + var MIN = ${MIN_FRAME_H}, MAX = ${MAX_FRAME_H}; + function frameFor(source) { + var frames = document.querySelectorAll('iframe.ss-frame'); + for (var i = 0; i < frames.length; i++) { + if (frames[i].contentWindow === source) return frames[i]; + } + return null; + } + window.addEventListener('message', function (e) { + var d = e.data; + if (!d || d.__sideshow !== true) return; + // Attribute every message to a frame we actually embedded (contentWindow + // identity holds across the opaque-origin boundary) — the export's + // isOwnFrame. A frame can only ever resize itself. + var frame = frameFor(e.source); + if (!frame) return; + if (d.type === 'resize') { + frame.style.height = Math.min(Math.max(Number(d.height) || MIN, MIN), MAX) + 'px'; + } else if (d.type === 'open-link') { + var url; + try { url = new URL(String(d.url)); } catch (err) { return; } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return; + // Same confirmation as the viewer (App.tsx): the request comes from an + // untrusted frame, so a disguised control could silently open any page — + // show the normalized destination and let the reader decide. + if (window.confirm('Open external link?\\n\\n' + url.href)) { + window.open(url.href, '_blank', 'noopener,noreferrer'); + } + } else if (d.type === 'copy') { + if (navigator.clipboard) navigator.clipboard.writeText(String(d.text)).catch(function () {}); + } + // send-prompt / switch-session: no-ops — a static file has no agent behind it. + }); + // Chrome 149 field trial breaks layout measurement in opaque-origin srcdoc + // iframes (scrollHeight/offsetHeight read 0), so the bridge reports only body + // padding and the frame sticks at min height. Re-setting srcdoc forces a + // re-parse that recovers layout; a no-op in unaffected browsers. (Ported from + // viewer/src/SandboxedPart.tsx at commit 5e3f292.) + var pending = document.querySelectorAll('iframe.ss-frame'); + for (var i = 0; i < pending.length; i++) { + (function (frame) { + setTimeout(function () { + if (frame.offsetHeight > MIN) return; + var doc = frame.getAttribute('srcdoc'); + if (doc == null) return; + frame.removeAttribute('srcdoc'); + requestAnimationFrame(function () { frame.setAttribute('srcdoc', doc); }); + }, 2000); + })(pending[i]); + } +})(); +`; + +// The shell's own chrome — a plain card column echoing the viewer's look, built +// from the derived theme vars so light/dark tracks the theme. No load-restricting +// CSP: srcdoc children inherit the parent document's CSP, so a policy here would +// break the CDN loads html/mermaid frames need. +function shellCss(theme: Theme, mode?: Mode): string { + return ` +${viewerThemeCss(theme, mode)} +${colorSchemeCss(mode)} +* { box-sizing: border-box; } +body { + margin: 0; padding: 24px 16px 64px; + background: var(--bg); color: var(--text); + font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} +.ss-shell { max-width: 820px; margin: 0 auto; } +.ss-header { margin: 0 0 24px; } +.ss-header h1 { font-size: 1.4em; margin: 0 0 4px; font-weight: 600; } +.ss-header .ss-sub { color: var(--muted); font-size: 0.85em; } +.ss-card { + background: var(--surface); border: 0.5px solid var(--border); + border-radius: 12px; margin: 0 0 20px; overflow: hidden; +} +.ss-card-head { + display: flex; align-items: baseline; gap: 10px; justify-content: space-between; + padding: 12px 16px; border-bottom: 0.5px solid var(--border); +} +.ss-card-head h2 { font-size: 1.05em; margin: 0; font-weight: 600; } +.ss-card-head .ss-meta { color: var(--faint); font-size: 0.78em; white-space: nowrap; } +.ss-frame { display: block; width: 100%; border: 0; height: ${MIN_FRAME_H}px; } +.ss-image { margin: 0; padding: 16px; } +.ss-image img { max-width: 100%; height: auto; border-radius: 8px; display: block; } +.ss-caption { color: var(--muted); font-size: 0.85em; margin-top: 8px; } +.ss-json { + margin: 0; padding: 14px 16px; overflow: auto; white-space: pre; + color: var(--text); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +.ss-note { margin: 0; padding: 14px 16px; color: var(--muted); font-size: 0.9em; font-style: italic; } +.ss-thread { border-top: 0.5px solid var(--border); padding: 8px 16px 12px; } +.ss-comment { padding: 8px 0; } +.ss-comment + .ss-comment { border-top: 0.5px solid var(--border); } +.ss-comment .ss-comment-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 2px; } +.ss-comment .ss-author { font-weight: 600; font-size: 0.85em; } +.ss-comment .ss-time { color: var(--faint); font-size: 0.75em; } +.ss-comment p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } +.ss-empty { color: var(--muted); font-style: italic; } +`; +} + +function commentThread(comments: Comment[]): string { + if (comments.length === 0) return ""; + const rows = comments + .map( + (c) => + `
${escapeHtml( + c.author === "user" ? "you" : c.author, + )}${escapeHtml(c.createdAt)}

${escapeHtml( + c.text, + )}

`, + ) + .join(""); + return `
${rows}
`; +} + +function card(item: RenderedPost): string { + const meta = `v${item.post.version} · ${escapeHtml(item.post.createdAt)}`; + const body = item.surfaces.join("\n"); + return `
+

${escapeHtml(item.post.title)}

${meta}
+
${body}
+${commentThread(item.comments)} +
`; +} + +// The trusted shell string. Pure/sync — the surfaces are already rendered by +// exportSurface (async), so this only assembles escaped/attribute-safe markup. +function renderSessionExportPage(opts: { + session: Session; + items: RenderedPost[]; + theme: Theme; + mode?: Mode; + generatedAt: string; +}): string { + const { session, items } = opts; + const heading = + session.title || (session.agent ? `${session.agent} session` : "sideshow session"); + const cards = + items.length > 0 + ? items.map(card).join("\n") + : `

This session has no posts yet.

`; + return ` + + + + +${escapeHtml(heading)} · sideshow + + + +
+
+

${escapeHtml(heading)}

+
Exported from sideshow · ${escapeHtml(opts.generatedAt)}
+
+${cards} +
+ + +`; +} + +// Orchestrator the route calls: resolve every surface (async renderers) and +// assemble the trusted shell. Kept here so app.ts stays thin and the whole +// export path is one runtime-agnostic module. +export async function renderSessionExport(opts: { + session: Session; + items: { post: Post; comments: Comment[] }[]; + origin: string; + themeId: string; + mode?: Mode; + generatedAt: string; + getAsset: (id: string) => Promise; + // Inline-asset budget override, for embedders and tests; defaults to + // MAX_EXPORT_ASSET_BYTES. + maxInlineAssetBytes?: number; +}): Promise { + const ctx: ExportContext = { + origin: opts.origin, + themeId: opts.themeId, + mode: opts.mode, + getAsset: opts.getAsset, + assetBytesRemaining: opts.maxInlineAssetBytes ?? MAX_EXPORT_ASSET_BYTES, + inlinedAssets: new Map(), + }; + // Sequential on purpose: rendering is CPU-bound on a single-threaded runtime, + // so Promise.all buys no wall-clock — it only holds every post's decoded + // assets and intermediate strings in memory at once. Sequencing bounds peak + // memory to one surface in flight (plus the accumulated output, which the + // asset budget caps) and makes budget accounting deterministic in document + // order. + const rendered: RenderedPost[] = []; + for (const item of opts.items) { + const surfaces: string[] = []; + for (const s of item.post.surfaces) { + surfaces.push(await exportSurface(s, ctx, item.post.title)); + } + rendered.push({ post: item.post, comments: item.comments, surfaces }); + } + return renderSessionExportPage({ + session: opts.session, + items: rendered, + theme: themeById(opts.themeId), + mode: opts.mode, + generatedAt: opts.generatedAt, + }); +} diff --git a/server/surfacePage.ts b/server/surfacePage.ts index ce26163..d5b21d6 100644 --- a/server/surfacePage.ts +++ b/server/surfacePage.ts @@ -1,4 +1,5 @@ import { kitAssets } from "./kits.ts"; +import { renderCode, renderDiff, renderMarkdown, renderTerminal } from "./richRender.ts"; import { type Mode, type Palette, @@ -8,6 +9,7 @@ import { tokenThemeCss, viewerThemeCss, } from "./themes.ts"; +import type { Surface } from "./types.ts"; // The kit's two custom SVG accent ramps (teal, coral) aren't in the theme // palette, so they carry their own light/dark values. Like the theme tokens @@ -35,7 +37,8 @@ const kitAccentCss = (mode?: Mode): string => schemeCss(KIT_ACCENTS_LIGHT, KIT_A // and native form controls follow the same scheme as the theme vars (the vars // alone don't drive those). Pinned frames get a single scheme; unpinned/direct // loads opt into both schemes so the browser can resolve the user's system mode. -const colorSchemeCss = (mode?: Mode): string => `:root{color-scheme:${mode ?? "light dark"}}`; +export const colorSchemeCss = (mode?: Mode): string => + `:root{color-scheme:${mode ?? "light dark"}}`; // Origins html surfaces may load external resources from. Mirrors the allowlist // agents already know from Claude's inline widget surface. @@ -279,8 +282,16 @@ if (window.ResizeObserver) { } `; -export const escapeHtml = (s: string) => - s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +// Single pass on purpose: inputs can be whole rendered documents (the session +// export srcdoc-escapes every frame), where four chained .replace scans copy +// the string four times. +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, +}; +export const escapeHtml = (s: string) => s.replace(/[&<>"]/g, (ch) => HTML_ESCAPES[ch]); // Wrap one html surface in the themed, sandboxed document the iframe loads. The // workspace's color tokens (theme-dependent) are injected first so the static base @@ -545,6 +556,12 @@ export function renderHtmlPage(doc: { // is plain inline script — same trust level as the bridge, already covered by // the html-surface CSP's `script-src 'unsafe-inline'`. Unknown ids are ignored. kits?: string[]; + // Base URL for resolving the document's relative refs. When this doc is served + // by real URL (/s/:id) the URL is already the base, so this is omitted. When + // it is embedded as srcdoc (the session export), srcdoc's base is about:srcdoc + // — so relative `/a/:id` asset refs would break; pass the server origin (e.g. + // "https://host/") to pin them. img-src in buildCsp already allows that origin. + baseHref?: string; }): string { const theme = typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme; @@ -555,7 +572,7 @@ export function renderHtmlPage(doc: { -${escapeHtml(doc.title)} +${doc.baseHref ? `\n` : ""}${escapeHtml(doc.title)} @@ -566,3 +583,51 @@ ${kit.js ? `` : ""} `; } + +// One surface → one complete sandboxed document, for every kind that becomes +// HTML. This is the single kind→renderer dispatch: /s/:id (app.ts) serves the +// result by real URL and the session export (exportPage.ts) embeds it as a +// srcdoc iframe, so the two can't drift when a kind or a renderer option +// changes. `baseHref` only matters to html surfaces embedded as srcdoc — the +// rich kinds pin unconditionally in renderSandboxedPart. Throws on the +// native kinds (image/json/trace); callers gate on isSandboxedSurfaceKind. +export async function renderSurfaceDocument( + surface: Surface, + doc: { title: string; origin: string; themeId: string; mode?: Mode; baseHref?: string }, +): Promise { + const { title, origin, themeId, mode, baseHref } = doc; + const theme = themeById(themeId); + if (surface.kind === "html") { + return renderHtmlPage({ + title, + html: surface.html, + origin, + theme, + mode, + kits: surface.kits, + baseHref, + }); + } + if (surface.kind === "mermaid") { + return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode }); + } + const rendered = + surface.kind === "markdown" + ? await renderMarkdown(surface, { theme: themeId, mode }) + : surface.kind === "code" + ? await renderCode(surface, { theme: themeId, mode }) + : surface.kind === "terminal" + ? renderTerminal(surface) + : surface.kind === "diff" + ? // A malformed patch degrades to an escaped error body instead of + // failing the whole document. + await renderDiff(surface, { theme: themeId, mode }).catch((e) => ({ + body: `
Couldn’t render diff — ${escapeHtml( + e instanceof Error ? e.message : "render error", + )}
`, + css: `.rich-error{color:var(--danger);font:13px/1.5 ui-monospace,monospace;padding:8px 12px;}`, + })) + : null; + if (!rendered) throw new Error(`surface kind "${surface.kind}" does not render to a document`); + return renderSandboxedPart({ body: rendered.body, css: rendered.css, origin, theme, mode }); +} diff --git a/server/types.ts b/server/types.ts index 3fbc964..e94501e 100644 --- a/server/types.ts +++ b/server/types.ts @@ -92,6 +92,11 @@ export const SURFACE_FRAME_CLASSES = Object.fromEntries( }), ) as Partial>; +// Clamp bounds for bridge-reported sandboxed-iframe heights, shared by the +// viewer (Card.tsx) and the session export shell so the two can't drift. +export const MIN_FRAME_H = 24; +export const MAX_FRAME_H = 4000; + export function isSurfaceKind(kind: unknown): kind is SurfaceKind { return typeof kind === "string" && Object.hasOwn(SURFACE_KIND_METADATA, kind); } @@ -463,6 +468,20 @@ export function stripNulStep(s: TraceStep): TraceStep { export const MAX_ASSET_BYTES = 5 * 1024 * 1024; export const MAX_WORKSPACE_ASSET_BYTES = 2 * 1024 * 1024 * 1024; +// The only content types trusted as-declared: raster image formats with no +// script capability. /a/:id serves these inline (everything else is an +// attachment — see assetServeHeaders in app.ts) and the session export inlines +// them as data: URIs. contentType is upload-controlled and otherwise unvalidated, +// so anything outside this set must never be interpolated into markup or served +// inline. Shared here so the two policies can't drift. +export const INLINE_IMAGE_TYPES: ReadonlySet = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", +]); + // Short, unguessable id: 8 random bytes (64 bits) as 11 url-safe base64 chars — // YouTube-video-id sized. These double as bearer capabilities: in publicRead // mode `/s/:id` and `/api/{sessions,surfaces}/:id` are reachable without the diff --git a/test/cli.test.ts b/test/cli.test.ts index 4e989e5..1cb179b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1232,6 +1232,33 @@ test("show without an id fails with a usage error", async () => { assert.match(stderr, /usage: sideshow show/); }); +// --- export ---------------------------------------------------------------- + +test("export writes a session's HTML to --out", async () => { + const server = await serveSession(); + try { + const html = tmpFile("c.html", "

exported

"); + await cli(server, "publish", html, "--title", "Exported card"); + const outFile = join(mkdtempSync(join(tmpdir(), "sideshow-export-out-")), "session.html"); + + const { code, stdout } = await cli(server, "export", "--out", outFile); + assert.equal(code, 0); + assert.match(stdout, /Wrote .*session\.html/); + const written = readFileSync(outFile, "utf8"); + assert.match(written, //i); + assert.ok(written.includes("Exported card")); + assert.ok(written.includes('sandbox="allow-scripts"')); + } finally { + await server.close(); + } +}); + +test("export with no resolvable session fails and never creates one", async () => { + const { code, stderr } = await run("export"); + assert.notEqual(code, 0); + assert.match(stderr, /pass --session/); +}); + // --- assets (image / upload / asset-url) ---------------------------------- test("image uploads bytes and publishes an image post", async () => { diff --git a/test/export.test.ts b/test/export.test.ts new file mode 100644 index 0000000..85f4513 --- /dev/null +++ b/test/export.test.ts @@ -0,0 +1,353 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createApp } from "../server/app.ts"; +import { renderSessionExport } from "../server/exportPage.ts"; +import { JsonFileStore } from "../server/storage.ts"; +import { decodeBase64, encodeBase64 } from "../server/base64.ts"; + +function makeAppWithStore(authToken?: string, opts?: { publicRead?: "session" | "full" }) { + const dir = mkdtempSync(join(tmpdir(), "sideshow-export-")); + const store = new JsonFileStore(join(dir, "data.json")); + const app = createApp({ + store, + viewerHtml: "viewer", + guideMarkdown: "# guide", + setupText: "# setup", + agentHowtoText: "# agent how-to", + authToken, + ...opts, + }); + return { app, store }; +} + +function makeApp(authToken?: string, opts?: { publicRead?: "session" | "full" }) { + return makeAppWithStore(authToken, opts).app; +} + +const json = (body: unknown, token?: string) => ({ + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), +}); + +// A 1x1 transparent PNG. +const TINY_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +async function publish(app: ReturnType, body: unknown, token?: string) { + const res = await app.request("/api/posts", json(body, token)); + const parsed = (await res.json()) as { id: string; sessionId: string; version: number }; + assert.equal(res.status, 201, JSON.stringify(parsed)); + return parsed; +} + +async function exportSession(app: ReturnType, sessionId: string, query = "") { + return app.request(`/api/sessions/${sessionId}/export${query}`); +} + +test("export renders one sandboxed srcdoc iframe per sandboxed surface, posts chronological", async () => { + const app = makeApp(); + + // Upload an asset so the image surface resolves. + const uploaded = (await ( + await app.request("/api/assets", json({ data: TINY_PNG_B64, contentType: "image/png" })) + ).json()) as { id: string; sessionId: string }; + const sessionId = uploaded.sessionId; + + const first = await publish(app, { + session: sessionId, + title: "First card", + surfaces: [ + { kind: "html", html: "

hello

" }, + { kind: "markdown", markdown: "# Heading\n\ntext" }, + { kind: "json", data: { a: 1, b: [2, 3] } }, + { kind: "image", assetId: uploaded.id }, + { kind: "terminal", text: "$ ls\nfile.txt" }, + ], + }); + assert.ok(first.id); + await publish(app, { + session: sessionId, + title: "Second card", + surfaces: [{ kind: "html", html: "

bye

" }], + }); + + const res = await exportSession(app, sessionId); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/html/); + const html = await res.text(); + + // html + markdown + terminal are sandboxed → 3 frames on the first card, plus + // 1 on the second = 4 srcdoc iframes; json + image are native (no iframe). + const frames = html.match(/sandbox="allow-scripts"/g) ?? []; + assert.equal(frames.length, 4); + // Every sandboxed surface carries srcdoc. + assert.equal( + (html.match(/`; + const { sessionId } = await publish(app, { + title: `">`, + surfaces: [{ kind: "html", html: HOSTILE }], + }); + + const html = await (await exportSession(app, sessionId)).text(); + // The raw breakout sequences never appear unescaped in the shell. + assert.ok(!html.includes("")); + assert.ok(!html.includes("")); + // Only their escaped forms survive. + assert.ok(html.includes("<script>alert(1)</script>")); +}); + +test("only allowlisted raster content types are inlined; a crafted MIME can't inject attributes", async () => { + const app = makeApp(); + // Distinct byte payloads — asset ids are content-addressed, so identical + // bytes would collapse to one asset and one contentType. + const hostile = (await ( + await app.request( + "/api/assets", + json({ + data: encodeBase64(new Uint8Array([1, 2, 3])), + // Breaks out of the src attribute if interpolated raw. + contentType: 'image/png" onload="alert(1)', + }), + ) + ).json()) as { id: string; sessionId: string }; + const svg = (await ( + await app.request( + "/api/assets", + json({ + data: encodeBase64(new Uint8Array([4, 5, 6])), + contentType: "image/svg+xml", + }), + ) + ).json()) as { id: string }; + + await publish(app, { + session: hostile.sessionId, + title: "mime", + surfaces: [ + { kind: "image", assetId: hostile.id }, + { kind: "image", assetId: svg.id }, + ], + }); + + const html = await (await exportSession(app, hostile.sessionId)).text(); + assert.ok(!html.includes("onload="), "crafted MIME never reaches the shell markup"); + assert.ok(!html.includes("data:image/svg"), "svg is not inlined"); + // Both degrade to the omitted note instead of rendering an . + assert.equal((html.match(/non-image content type/g) ?? []).length, 2); + assert.ok(!html.includes(" { + const { app, store } = makeAppWithStore(); + const first = (await ( + await app.request("/api/assets", json({ data: TINY_PNG_B64, contentType: "image/png" })) + ).json()) as { id: string; sessionId: string }; + const second = (await ( + await app.request( + "/api/assets", + json({ data: encodeBase64(new Uint8Array(100)), contentType: "image/png" }), + ) + ).json()) as { id: string }; + + await publish(app, { + session: first.sessionId, + title: "budget", + surfaces: [ + { kind: "image", assetId: first.id }, + { kind: "image", assetId: second.id }, + // A re-reference of the already-inlined asset still charges the budget. + { kind: "image", assetId: first.id }, + ], + }); + + const session = await store.getSession(first.sessionId); + assert.ok(session); + const posts = await store.listPosts(session.id); + const firstBytes = decodeBase64(TINY_PNG_B64).byteLength; + const html = await renderSessionExport({ + session, + items: posts.map((post) => ({ post, comments: [] })), + origin: "http://localhost:8228", + themeId: "github", + generatedAt: "2026-01-01T00:00:00.000Z", + getAsset: (id) => store.getAsset(id), + // Exactly one copy of the first asset fits. + maxInlineAssetBytes: firstBytes, + }); + + assert.equal((html.match(/data:image\/png;base64,/g) ?? []).length, 1, "one image inlined"); + assert.equal((html.match(/inline-image size limit/g) ?? []).length, 2, "two omitted with notes"); +}); + +test("a session over the aggregate surface-byte cap 413s before rendering", async () => { + const app = makeApp(); + // 3 × 1.5 MB html surfaces: each under the 2 MB per-post cap, together over + // the 4 MB export input cap. + const big = "x".repeat(1.5 * 1024 * 1024); + const { sessionId } = await publish(app, { + title: "big 1", + surfaces: [{ kind: "html", html: big }], + }); + await publish(app, { + session: sessionId, + title: "big 2", + surfaces: [{ kind: "html", html: big }], + }); + await publish(app, { + session: sessionId, + title: "big 3", + surfaces: [{ kind: "html", html: big }], + }); + + const res = await exportSession(app, sessionId); + assert.equal(res.status, 413); + const body = (await res.json()) as { error: string }; + assert.match(body.error, /too large to export/); +}); + +test("the shell's open-link bridge confirms the destination before opening", async () => { + const app = makeApp(); + const { sessionId } = await publish(app, { + title: "links", + surfaces: [{ kind: "html", html: "

x

" }], + }); + const html = await (await exportSession(app, sessionId)).text(); + // Mirrors the viewer (App.tsx): untrusted frames can request opens for any + // URL, so the normalized href must be confirmed by the reader first. + assert.ok(html.includes("window.confirm('Open external link?\\n\\n' + url.href)")); + const confirmAt = html.indexOf("window.confirm('Open external link?"); + const openAt = html.indexOf("window.open(url.href"); + assert.ok(confirmAt !== -1 && openAt !== -1 && confirmAt < openAt, "confirm gates window.open"); +}); + +test("json surface with a script string is escaped in a
", async () => {
+  const app = makeApp();
+  const { sessionId } = await publish(app, {
+    title: "json",
+    surfaces: [{ kind: "json", data: { danger: "" } }],
+  });
+  const html = await (await exportSession(app, sessionId)).text();
+  assert.ok(!html.includes(""));
+  assert.ok(html.includes("<script>alert(1)</script>"));
+});
+
+test("post-anchored comments render escaped; session-level comments excluded", async () => {
+  const app = makeApp();
+  const { id, sessionId } = await publish(app, {
+    title: "commented",
+    surfaces: [{ kind: "html", html: "

x

" }], + }); + await app.request( + "/api/comments", + json({ surface: id, text: "great work", author: "user" }), + ); + // A session-level comment (no postId) must not appear. + await app.request("/api/comments", json({ text: "session note", author: "user" })); + + const html = await (await exportSession(app, sessionId)).text(); + assert.ok(html.includes("great <b>work</b>"), "comment text present and escaped"); + assert.ok(!html.includes("session note"), "session-level comment excluded"); +}); + +test("empty session exports an empty-state; unknown session id 404s", async () => { + const app = makeApp(); + const session = (await (await app.request("/api/sessions", json({ agent: "e2e" }))).json()) as { + id: string; + }; + const html = await (await exportSession(app, session.id)).text(); + assert.ok(html.includes("no posts yet")); + + const missing = await exportSession(app, "nope"); + assert.equal(missing.status, 404); +}); + +test("auth: token app 401s unauthenticated, ?key works; publicRead session app is open", async () => { + const app = makeApp("secret"); + const { sessionId } = await publish( + app, + { title: "t", surfaces: [{ kind: "html", html: "

x

" }] }, + "secret", + ); + + const unauth = await exportSession(app, sessionId); + assert.equal(unauth.status, 401); + const withKey = await exportSession(app, sessionId, "?key=secret"); + assert.equal(withKey.status, 200); + + const openApp = makeApp("secret", { publicRead: "session" }); + const pub = await publish( + openApp, + { title: "t", surfaces: [{ kind: "html", html: "

x

" }] }, + "secret", + ); + const openRes = await exportSession(openApp, pub.sessionId); + assert.equal(openRes.status, 200); +}); + +test("?theme + ?mode pin the palette into frames; ?download sets a sanitized filename", async () => { + const app = makeApp(); + const { sessionId } = await publish(app, { + title: "Themed export!!", + sessionTitle: "My Session / Name", + surfaces: [{ kind: "markdown", markdown: "text" }], + }); + + const res = await exportSession(app, sessionId, "?theme=gruvbox&mode=dark"); + const html = await res.text(); + // A hex from the gruvbox dark palette (surface) baked into the pinned frames/shell. + assert.ok(html.includes("#32302f"), "gruvbox dark palette pinned"); + + const dl = await exportSession(app, sessionId, "?download=1"); + const disp = dl.headers.get("content-disposition") ?? ""; + assert.match(disp, /attachment; filename="sideshow-[a-z0-9-]+\.html"/); +}); + +test("encodeBase64 round-trips with decodeBase64 across the chunk boundary", async () => { + for (const n of [0, 1, 255, 0x8000 - 1, 0x8000, 0x8000 + 123, 0x10001]) { + const bytes = new Uint8Array(n); + for (let i = 0; i < n; i++) bytes[i] = (i * 31 + 7) & 0xff; + const round = decodeBase64(encodeBase64(bytes)); + assert.equal(round.length, n); + assert.deepEqual([...round], [...bytes]); + } +}); diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index 9cbf7de..dd41079 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -24,7 +24,12 @@ import { postLink, postImageLink, } from "./api.ts"; -import { isSandboxedSurfaceKind, SURFACE_FRAME_CLASSES } from "../../server/types.ts"; +import { + isSandboxedSurfaceKind, + MAX_FRAME_H, + MIN_FRAME_H, + SURFACE_FRAME_CLASSES, +} from "../../server/types.ts"; import { CommentIcon, ImageIcon, @@ -68,9 +73,8 @@ export function frameForSource(source: unknown): { id: string; iframe: HTMLIFram } // Size a post's surface iframe from a height the in-frame bridge reported. Min -// one line, max generous enough for a long diff/markdown without runaway growth. -const MIN_FRAME_H = 24; -const MAX_FRAME_H = 4000; +// one line, max generous enough for a long diff/markdown without runaway growth +// (bounds shared with the session export shell via server/types.ts). const RECENT_UPDATE_MS = 2 * 60 * 1000; export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unknown): void { iframe.style.height = Math.min(Math.max(Number(reportedHeight), MIN_FRAME_H), MAX_FRAME_H) + "px";