Public: anyone can read this; the crew can change it.
A pin's screenshots get a gallery page on the builds page #9302
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9218-a-pin-s-screenshots-get-a-gallery-page-o → main 7 commits fork 2 threads · 2 unresolved
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, | ||
| 15 | 15 | - **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. |
| 16 | 16 | - **Live.** Every change emits `build.shots` (`added`, `picked`, `moved`, `captioned`, `removed`), read by the repository's readers. |
| 17 | 17 | |
| 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 { | ||
| 185 | 185 | } from "../web/pages/branches.ts"; |
| 186 | 186 | import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts"; |
| 187 | 187 | 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"; | |
| 189 | 189 | import { cartArt, cartPage } from "../web/pages/build_cart.ts"; |
| 190 | 190 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; |
| 191 | 191 | import { |
| @@ -1263,6 +1263,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1263 | 1263 | router.add("GET", "/:owner/:name/builds", buildsPage); |
| 1264 | 1264 | router.add("GET", "/:owner/:name/builds/new", newBuildPage); |
| 1265 | 1265 | // 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); | |
| 1266 | 1267 | router.add("GET", "/:owner/:name/builds/:label/shots/:file", shotImage); |
| 1267 | 1268 | // A pin's cart: its captured page, played sandboxed, and its picture (`arcade/carts.ts`). |
| 1268 | 1269 | router.add("GET", "/:owner/:name/builds/:label/cart", cartPage); |
src/web/pages/build_shots.ts +38 −3
| @@ -1,14 +1,49 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A pin's screenshots, read: one picture by name, to whoever reads the |
| 3 | 3 | * 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. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | import { readShotBytes } from "../../git/shots.ts"; | |
| 8 | import { readBuildPin } from "../../git/builds.ts"; | |
| 9 | import { readShotBytes, readShots } from "../../git/shots.ts"; | |
| 8 | 10 | import { repoDir } from "../../git/repos.ts"; |
| 9 | 11 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 10 | 12 | 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. | |
| 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 | } | |
| 12 | 47 | |
| 13 | 48 | // GET /:owner/:name/builds/:label/shots/:file — one screenshot. Typed by |
| 14 | 49 | // 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( | ||
| 113 | 113 | >▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a |
| 114 | 114 | >`, |
| 115 | 115 | )} |
| 116 | ${when( | |
| 117 | n.from === undefined, | |
| 118 | () => html`<a class="build-shots small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/shots">Screenshots</a>`, | |
| 119 | )} | |
| 116 | 120 | ${when( |
| 117 | 121 | canPreview && n.from === undefined, |
| 118 | 122 | () => 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 | ||
| 159 | 163 | .build-note{min-width:0;flex:1} |
| 160 | 164 | .build-meta{flex:none;margin-left:auto} |
| 161 | 165 | .build-preview{flex:none;margin:0} |
| 166 | .build-shots{flex:none} | |
| 162 | 167 | .builds-none{padding:10px 14px;margin:0} |
| 163 | 168 | (max-width:640px){.build-meta{display:none}} |
| 164 | 169 | `; |
test/build-shots.test.ts +38
| @@ -167,6 +167,37 @@ describe("pin screenshots", () => { | ||
| 167 | 167 | expect((await get("/alice/karts/builds/v1/shots/..%2Fa.txt")).status).toBe(404); |
| 168 | 168 | }); |
| 169 | 169 | |
| 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: |
||
| 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 = "<img src=x onerror=1>&""; | |
| 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 | ||
| 170 | 201 | test("a picture is one by its first bytes, within 2 MB and 12 a pin", async () => { |
| 171 | 202 | const before = (await shots()).tree; |
| 172 | 203 | // A page named .png is refused; so is a picture past the limit. |
| @@ -376,6 +407,13 @@ describe("pin screenshots", () => { | ||
| 376 | 407 | expect(refs(forkDir, p)).toBe(refs(dir(), p)); |
| 377 | 408 | } |
| 378 | 409 | 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"); | |
| 379 | 417 | }); |
| 380 | 418 | |
| 381 | 419 | 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> = { | ||
| 58 | 58 | "/:owner/:name/bets/:id": BET, |
| 59 | 59 | "/:owner/:name/builds/:label/cart": CART, |
| 60 | 60 | "/:owner/:name/builds/:label/cart.png": CART, |
| 61 | "/:owner/:name/builds/:label/shots": SHOT, | |
| 61 | 62 | "/:owner/:name/builds/:label/shots/:file": SHOT, |
| 62 | 63 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, |
| 63 | 64 | "/:owner/:name/milestones/:number/limb/:op": LIMB, |
new above
offline
--s-3and--bg-2are 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.