Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cache-social-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Cache fully pinned social-preview screenshots at the Cloudflare edge so repeated link unfurls avoid redundant Browser Rendering calls. Access, post existence, and revision are revalidated on every edge request, and token-protected boards keep private client cache headers.
39 changes: 30 additions & 9 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,11 +918,25 @@ export function createApp({
return injectHead(text, `<script>${config}</script>`);
};

const postPreviewHead = (post: Post, request: Request) => {
const postPreviewHead = (
post: Post,
request: Request,
themeId: string,
rendererGeneration: string,
) => {
const origin = new URL(request.url).origin;
const publicBasePath = requestBasePath(request);
const canonical = `${origin}${publicBasePath}/p/${post.id}`;
const image = `${origin}${publicBasePath}/p/${post.id}.png?card=1`;
// Pin every pixel-affecting input in the advertised URL: post revision,
// workspace theme, deterministic color mode, and app/renderer generation.
// The Worker validates these before admitting the image to edge cache.
const imageUrl = new URL(`${origin}${publicBasePath}/p/${post.id}.png`);
imageUrl.searchParams.set("card", "1");
imageUrl.searchParams.set("theme", themeId);
imageUrl.searchParams.set("mode", "dark");
imageUrl.searchParams.set("v", String(post.version));
imageUrl.searchParams.set("g", rendererGeneration);
const image = imageUrl.toString();
const title = escapeHtml(post.title);
const description = "A https://sideshow.sh surface";
return [
Expand All @@ -941,7 +955,10 @@ export function createApp({
].join("\n");
};

const configuredViewerHtml = (c: Context, opts: { post?: Post; title?: string | null } = {}) => {
const configuredViewerHtml = async (
c: Context,
opts: { post?: Post; title?: string | null } = {},
) => {
// The viewer HTML is the trusted app origin — it shares that origin with the
// authenticated API and the comment→agent channel, so a cross-origin page
// that frames it could clickjack actions or the prompt-injection channel.
Expand All @@ -960,16 +977,20 @@ export function createApp({
),
pageTitle,
);
return opts.post ? injectHead(html, postPreviewHead(opts.post, c.req.raw)) : html;
if (!opts.post) return html;
const themeId = (await store.getSetting("theme")) ?? DEFAULT_THEME_ID;
return injectHead(html, postPreviewHead(opts.post, c.req.raw, themeId, version ?? "dev"));
};
app.get("/", (c) => c.html(configuredViewerHtml(c)));
app.get("/connect", (c) => c.html(configuredViewerHtml(c, { title: "Connect an agent" })));
app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
app.get("/connect", async (c) =>
c.html(await configuredViewerHtml(c, { title: "Connect an agent" })),
);
app.get("/session/:id", async (c) => {
const session = await store.getSession(c.req.param("id"));
if (isUnauthenticatedSessionRead(c) && !session) {
return c.text("Session not found", 404);
}
return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
return c.html(await configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
});
const sessionPostPage = async (c: any) => {
const session = await store.getSession(c.req.param("id"));
Expand All @@ -980,7 +1001,7 @@ export function createApp({
return c.text("Session or post not found", 404);
}
}
return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
return c.html(await configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
};
app.get("/session/:id/s/:surfaceId", sessionPostPage); // legacy alias
app.get("/session/:id/p/:postId", sessionPostPage);
Expand Down Expand Up @@ -1526,7 +1547,7 @@ export function createApp({
if (!post) return c.text("Post not found", 404);
// `part` is the legacy query key; `surface` is canonical.
const surfaceParam = c.req.query("surface") ?? c.req.query("part");
if (surfaceParam == null) return c.html(configuredViewerHtml(c, { post }));
if (surfaceParam == null) return c.html(await configuredViewerHtml(c, { post }));

const ver = c.req.query("ver");
let title = post.title;
Expand Down
23 changes: 19 additions & 4 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function makeApp(
basePath?: string;
viewerHtml?: string;
screenshots?: boolean;
version?: string;
maxHoldConnections?: number;
onEvent?: Parameters<typeof createApp>[0]["onEvent"];
store?: Store;
Expand Down Expand Up @@ -359,8 +360,8 @@ test("GET /session/:id serves the viewer shell with the session title", async ()
assert.match(body, /<title>Auth refactor · sideshow<\/title>/);
});

test("GET /s/:id emits absolute token-free canonical and preview image URLs", async () => {
const app = makeApp("secret");
test("GET /s/:id emits fully pinned, absolute, token-free preview image URLs", async () => {
const app = makeApp("secret", { version: "1.2.3" });
const res = await app.request(
"https://board.test/api/snippets",
authedJson({ title: "Preview", html: "<p>x</p>" }),
Expand All @@ -369,7 +370,7 @@ test("GET /s/:id emits absolute token-free canonical and preview image URLs", as

const body = await (await app.request(`https://board.test/s/${surface.id}?key=secret`)).text();
const canonical = `https://board.test/p/${surface.id}`;
const image = `https://board.test/p/${surface.id}.png?card=1`;
const image = `https://board.test/p/${surface.id}.png?card=1&amp;theme=github&amp;mode=dark&amp;v=${surface.version}&amp;g=1.2.3`;
assert.match(body, new RegExp(`<link rel="canonical" href="${canonical}">`));
assert.match(body, new RegExp(`<meta property="og:url" content="${canonical}">`));
assert.match(
Expand Down Expand Up @@ -430,12 +431,26 @@ test("GET /s/:id preview metadata respects configured base path", async () => {
assert.match(
body,
new RegExp(
`<meta property="og:image" content="https://board.test/u/alice/p/${surface.id}\\.png\\?card=1">`,
`<meta property="og:image" content="https://board.test/u/alice/p/${surface.id}\\.png\\?card=1&amp;theme=github&amp;mode=dark&amp;v=${surface.version}&amp;g=dev">`,
),
);
assert.match(body, /window\.__SIDESHOW_BASE_PATH__="\/u\/alice"/);
});

test("post preview image URL changes with the workspace theme", async () => {
const app = makeApp(undefined, { version: "1.2.3" });
const res = await app.request("/api/snippets", json({ title: "Themed", html: "<p>x</p>" }));
const post = (await res.json()) as any;

const before = await (await app.request(`/p/${post.id}`)).text();
assert.match(before, /theme=github/);
const update = await app.request("/api/theme", { ...json({ id: "gruvbox" }), method: "PUT" });
assert.equal(update.status, 200);
const after = await (await app.request(`/p/${post.id}`)).text();
assert.match(after, /theme=gruvbox/);
assert.doesNotMatch(after, /theme=github/);
});

test("/s served versioned + themed is cacheable; an unpinned load is not", async () => {
const app = makeApp();
const res = await app.request(
Expand Down
8 changes: 5 additions & 3 deletions test/workerScreenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ test("post screenshot route matches GET and HEAD requests without baking in an i

test("card screenshots use stable social-card dimensions without fullPage", () => {
const plan = planPostScreenshot(
new URL("https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&key=secret"),
new URL(
"https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&v=7&key=secret",
),
"abc123",
"sideshow_mode=light",
);

assert.deepEqual(plan.viewport, { width: 1200, height: 630 });
assert.deepEqual(plan.screenshotOptions, { fullPage: false });
assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&theme=gruvbox&mode=dark");
assert.doesNotMatch(plan.target, /key=secret|card=1|w=640/);
assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&ver=7&theme=gruvbox&mode=dark");
assert.doesNotMatch(plan.target, /key=secret|card=1|w=640|(?:^|[?&])v=/);
});

test("non-card screenshots preserve full-page behavior and configurable width", () => {
Expand Down
Loading