Public: anyone can read this; the crew can change it.

A pin's screenshots get a gallery page on the builds page #9302

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9218-a-pin-s-screenshots-get-a-gallery-page-o → main 7 commits fork 2 threads · 2 unresolved

  • Merged. mason/9218-a-pin-s-screenshots-get-a-gallery-page-o was merged into main as 485d6e5.

Review

Sign in to review this change.
6 files changed +87 −5
docs/features/pin-screenshots.md +3 −1
@@ -15,4 +15,6 @@ A build pin can keep curated screenshots: PNG, JPEG or WebP pictures, in order,
1515 - **Forks.** A fork starts with its parent's pins, covers and screenshots as they are at the fork (`refs/builds`, `refs/covers`, `refs/shots`). Pins and screenshots added upstream later are not synced. A fork whose pin refs cannot be copied is not made, and leaves nothing behind.
1616 - **Live.** Every change emits `build.shots` (`added`, `picked`, `moved`, `captioned`, `removed`), read by the repository's readers.
1717
18 Left out: a gallery page, the demo/arcade side of Jay Moran's port, activity wording for `build.shots`.
18 - **Gallery.** `GET /:owner/:name/builds/:label/shots` shows the pictures with their captions, linked from each pin on the Builds page, and re-reads itself on `build.shots`.
19
20 Left out: the demo/arcade side of Jay Moran's port, activity wording for `build.shots`.
src/http/server.ts +2 −1
@@ -185,7 +185,7 @@ import {
185185 } from "../web/pages/branches.ts";
186186 import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts";
187187 import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts";
188 import { shotImage } from "../web/pages/build_shots.ts";
188 import { shotImage, shotsPage } from "../web/pages/build_shots.ts";
189189 import { cartArt, cartPage } from "../web/pages/build_cart.ts";
190190 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
191191 import {
@@ -1263,6 +1263,7 @@ export function buildRouter(): Router<Handler> {
12631263 router.add("GET", "/:owner/:name/builds", buildsPage);
12641264 router.add("GET", "/:owner/:name/builds/new", newBuildPage);
12651265 // A pin's screenshot, read: a tree kept by refs/shots/<label> (`git/shots.ts`).
1266 router.add("GET", "/:owner/:name/builds/:label/shots", shotsPage);
12661267 router.add("GET", "/:owner/:name/builds/:label/shots/:file", shotImage);
12671268 // A pin's cart: its captured page, played sandboxed, and its picture (`arcade/carts.ts`).
12681269 router.add("GET", "/:owner/:name/builds/:label/cart", cartPage);
src/web/pages/build_shots.ts +38 −3
@@ -1,14 +1,49 @@
11 /**
22 * A pin's screenshots, read: one picture by name, to whoever reads the
33 * repository (`git/shots.ts`). They are written through the API and the
4 * terminal (`actions/build_shots.ts`); there is no gallery page yet.
4 * terminal (`actions/build_shots.ts`); the gallery page below shows them,
5 * following `build.shots` live.
56 */
67
7 import { readShotBytes } from "../../git/shots.ts";
8 import { readBuildPin } from "../../git/builds.ts";
9 import { readShotBytes, readShots } from "../../git/shots.ts";
810 import { repoDir } from "../../git/repos.ts";
911 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1012 import type { Ctx } from "../../http/context.ts";
11 import { loadRepo, notFoundPage } from "../chrome.ts";
13 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts";
14 import { html, type Html } from "../html.ts";
15 import { page } from "../layout.ts";
16 import { card } from "../ui.ts";
17
18 export const SHOTS_LIVE = "build.shots,build.pinned";
19
20 // GET /:owner/:name/builds/:label/shots — the gallery: server-rendered, re-fetched on build.shots.
outdated

--s-3 and --bg-2 are not tokens in this design system; the fallbacks are what render. Use real tokens and the documented look/figure component rather than a new hand-rolled grid with a raw colour.

21 export async function shotsPage(ctx: Ctx): Promise<Response> {
22 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
23 const repo = loadRepo(ctx);
24 if (!repo) return notFoundPage(ctx);
25 const label = ctx.params.label ?? "";
26 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
27 if ((await timePhase(ctx.request, "read", () => readBuildPin(dir, label))) === undefined) return notFoundPage(ctx);
28 const shots = await readShots(dir, label);
29 const base = repoBase(repo);
30 const at = `${base}/builds/${encodeURIComponent(label)}/shots`;
31 const grid: Html =
32 shots.items.length === 0
33 ? html`<p class="muted small">No screenshots on ${label} yet.</p>`
34 : html`<div class="looks">${shots.items.map(
35 (s) => html`<figure>
36 <a class="look" href="${at}/${s.file}?v=${s.oid.slice(0, 12)}"><img src="${at}/${s.file}?v=${s.oid.slice(0, 12)}" alt="${s.caption === "" ? `Screenshot ${s.file}` : s.caption}" width="320" height="240" loading="lazy" decoding="async" /></a>
37 ${s.caption === "" ? null : html`<figcaption class="small">${s.caption}</figcaption>`}
38 </figure>`,
39 )}</div>`;
40 const body = html`${repoHeader(ctx, repo, "branches", { compact: true })}
41 <div class="mt"><p class="small"><a href="${base}/builds">← Builds</a></p>
42 <div id="pin-shots" data-live="${SHOTS_LIVE}" data-live-repo="${repo.ownerName}/${repo.name}" aria-live="polite">${card(grid, { head: html`<h2>Screenshots · ${label}</h2>` })}</div></div>`;
43 return timePhase(ctx.request, "render", () =>
44 page({ ctx, title: `Screenshots ${label} · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }),
45 );
46 }
1247
1348 // GET /:owner/:name/builds/:label/shots/:file — one screenshot. Typed by
1449 // its first bytes, never by its name, and never sniffed into anything else.
src/web/pages/builds.ts +5
@@ -113,6 +113,10 @@ export function buildsCard(
113113 >▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a
114114 >`,
115115 )}
116 ${when(
117 n.from === undefined,
118 () => html`<a class="build-shots small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/shots">Screenshots</a>`,
119 )}
116120 ${when(
117121 canPreview && n.from === undefined,
118122 () => html`<form method="post" action="${base}/builds/preview" class="build-preview">
@@ -159,6 +163,7 @@ ${[1, 2, 3, 4, 5, 6, 7, 8].map((d) => `.build-row[data-depth="${d}"]{padding-lef
159163 .build-note{min-width:0;flex:1}
160164 .build-meta{flex:none;margin-left:auto}
161165 .build-preview{flex:none;margin:0}
166 .build-shots{flex:none}
162167 .builds-none{padding:10px 14px;margin:0}
163168 @media (max-width:640px){.build-meta{display:none}}
164169 `;
test/build-shots.test.ts +38
@@ -167,6 +167,37 @@ describe("pin screenshots", () => {
167167 expect((await get("/alice/karts/builds/v1/shots/..%2Fa.txt")).status).toBe(404);
168168 });
169169
170 test("the gallery page shows the pictures, escapes captions, hides a private pin, and is linked from Builds", async () => {
171 const page = await get("/alice/karts/builds/v1/shots", "bob");
172 expect(page.status).toBe(200);
173 const text = await page.text();
174 expect(text).toContain("/alice/karts/builds/v1/shots/01.png?v=");
175 expect(text).toContain('data-live="build.shots');

This does not test escaping: <script>alert is the content of a picture's bytes, not a caption. Upload a caption containing markup and assert the escaped output in figcaption and alt.

176 expect((await get("/alice/karts/builds/nope/shots")).status).toBe(404);
177 expect((await get("/alice/secret/builds/v1/shots", "bob")).status).toBe(404);
178 expect((await get("/alice/secret/builds/v1/shots")).status).toBe(404);
179 expect((await get("/alice/secret/builds/v1/shots", "alice")).status).toBe(200);
180 expect(await (await get("/alice/karts/builds", "bob")).text()).toContain("/alice/karts/builds/v1/shots");
181 });
182
183 test("the gallery escapes a caption in figcaption and alt, shows it, says when a pin has none, and only the pin's own repo links it", async () => {
184 const pinned = await pinBuild(app, alice, repo, { label: "markup", oid });
185 if (!pinned.ok) throw new Error(pinned.error);
186 // Empty pin: the words, no picture.
187 const empty = await (await get("/alice/karts/builds/markup/shots", "bob")).text();
188 expect(empty).toContain("No screenshots on markup yet.");
189 expect(empty).not.toContain("<figure");
190 const hostile = '<img src=x onerror=1>&"';
191 const up = await uploadShot(app, alice, repo, "markup", PNG(7), hostile);
192 if (!up.ok) throw new Error(up.error);
193 const text = await (await get("/alice/karts/builds/markup/shots", "bob")).text();
194 const escaped = "&lt;img src=x onerror=1&gt;&amp;&quot;";
195 expect(text).toContain(`<figcaption class="small">${escaped}</figcaption>`);
196 expect(text).toContain(`alt="${escaped}"`);
197 expect(text).not.toContain("<img src=x");
198 expect(text).not.toContain("No screenshots on markup");
199 });
200
170201 test("a picture is one by its first bytes, within 2 MB and 12 a pin", async () => {
171202 const before = (await shots()).tree;
172203 // A page named .png is refused; so is a picture past the limit.
@@ -376,6 +407,13 @@ describe("pin screenshots", () => {
376407 expect(refs(forkDir, p)).toBe(refs(dir(), p));
377408 }
378409 expect((await readShots(forkDir, "v1")).items.length).toBeGreaterThan(0);
410 // A derived row (the parent's pin on the fork's builds page) gets no link; the fork's own pins do.
411 const own = await pinBuild(app, alice, forked.value, { label: "mine", oid: (await commitToBranch(app, alice, forked.value, { message: "f", files: [{ path: "f.txt", content: "f\n" }] }) as { value: { commitOid: string } }).value.commitOid });
412 if (!own.ok) throw new Error(own.error);
413 const builds = await (await get("/alice/karts-fork/builds", "alice")).text();
414 expect(builds).toContain("alice/karts:");
415 expect(builds).toContain("/alice/karts-fork/builds/mine/shots");
416 expect(builds).not.toContain("/builds/v1/shots");
379417 });
380418
381419 test("a fork whose pin refs cannot be copied is removed, so a second try finds a free place", async () => {
test/security-floor-routes.test.ts +1
@@ -58,6 +58,7 @@ const UNSEEDED: Record<string, string> = {
5858 "/:owner/:name/bets/:id": BET,
5959 "/:owner/:name/builds/:label/cart": CART,
6060 "/:owner/:name/builds/:label/cart.png": CART,
61 "/:owner/:name/builds/:label/shots": SHOT,
6162 "/:owner/:name/builds/:label/shots/:file": SHOT,
6263 "/:owner/:name/looks/:env/:commit/:kind.png": LOOK,
6364 "/:owner/:name/milestones/:number/limb/:op": LIMB,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs