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

Port Jay Moran's build screenshots: build_shots, pin_demos, git/shots and the shots routes #9210

Merged opened this

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

Merged

scout/forge:scout/9194-port-jay-moran-s-build-screenshots-build → main 3 commits fork

  • Merged. scout/9194-port-jay-moran-s-build-screenshots-build was merged into main as 7867e4b.

Review

Sign in to review this change.
24 files changed +1149 −3
docs/features/pin-screenshots.md added +17
@@ -0,0 +1,17 @@
1 # Pin screenshots
2
3 A build pin can keep curated screenshots: PNG, JPEG or WebP pictures, in order, each with a one-line caption, kept in git as a tree under `refs/shots/<label>` (`git/shots.ts`). They go with the pin: deleting the pin deletes them, and a label pinned again starts bare.
4
5 - **Who.** Anyone who reads the repository lists and fetches them. Changing them needs the `rw` scope and a person who writes the repository: an agent is refused (403), a mirror's builds are its origin's (409).
6 - **Limits.** 2 MB a picture, 12 a pin, a caption of one line at most 140 characters. A picture is typed by its first bytes, never its name.
7 - **API.**
8 - `GET /api/v1/repos/:owner/:name/builds/:label/shots` lists `{ items, next_cursor, tree }`; each item has `file`, `caption`, `type`, `size` and a `url`.
9 - `POST …/shots` uploads one: multipart with the picture as `shot`, optional `caption` and `at`.
10 - `GET …/shots/looks` lists the pictures the forge took of the pin's commit that you may fetch; `POST …/shots/looks` with `{ "looks": [id, …] }` copies them in, in that order. Picking publishes them to everyone who reads the repository.
11 - `POST …/shots/:file` takes `{ caption }` and/or `{ move: "up" | "down" }`; `DELETE …/shots/:file` removes one.
12 - Every write may carry `at`, the `tree` it was read from (the `at` query parameter on DELETE); a list changed since answers 409 `moved`. Two writers racing without `at` still cannot both win: the loser gets `moved`.
13 - `GET /:owner/:name/builds/:label/shots/:file` serves the picture (immutable when `?v=` matches).
14 - **Terminal.** **Builds** → `s` on a pin lists its screenshots; a writer has `p` pick from the forge's pictures of that commit, `c` caption, `u`/`d` move, `x` remove. Upload is the API's: a terminal has no file of yours to send.
15 - **Live.** Every change emits `build.shots` (`added`, `picked`, `moved`, `captioned`, `removed`), read by the repository's readers.
16
17 Left out: a gallery page, the demo/arcade side of Jay Moran's port, copying `refs/shots` into forks, activity wording for `build.shots`.
src/actions/build_shots.ts added +201
@@ -0,0 +1,201 @@
1 /**
2 * A pin's screenshots (`git/shots.ts`): a writer picks them from the
3 * pictures the forge took when a deploy of the pin's commit came up
4 * (`looks/service.ts`), or uploads them, then orders them, captions them
5 * and removes them: a person who writes the repository, never an agent.
6 * Each change may name the tree it was read from (`at`); a list someone
7 * else changed since is refused as `moved`.
8 *
9 * Picking publishes: a phone or desktop picture taken as the owner is the
10 * writers' to see, and once it is a screenshot anyone who reads the
11 * repository sees it. A writer chooses that, one picture at a time.
12 */
13
14 import { join } from "node:path";
15 import type { App } from "../app.ts";
16 import { getAppByRepo } from "../core/apps.ts";
17 import { looksForCommits, type Look } from "../core/looks.ts";
18 import type { Repo } from "../core/repos.ts";
19 import type { User } from "../core/users.ts";
20 import { readBuildPin, type BuildPin } from "../git/builds.ts";
21 import { repoDir } from "../git/repos.ts";
22 import {
23 cleanCaption,
24 isCaption,
25 readShots,
26 SHOTS_LIMIT,
27 storeShot,
28 writeShots,
29 type ShotEntry,
30 type Shots,
31 type WriteShotsError,
32 } from "../git/shots.ts";
33 import { err, ok, type Result } from "../kernel/result.ts";
34 import type { AccessError } from "./access.ts";
35 import { writerOf } from "./builds.ts";
36
37 export type ShotsError =
38 | AccessError
39 | WriteShotsError
40 | "mirrored"
41 | "invalid_shot"
42 | "too_large"
43 | "no_such_shot"
44 | "nothing_picked";
45
46 /** What one change did, as its event says it. */
47 type Change = "added" | "picked" | "moved" | "captioned" | "removed";
48
49 interface Edit {
50 readonly writer: User;
51 readonly dir: string;
52 readonly pin: BuildPin;
53 readonly shots: Shots;
54 }
55
56 /** Authorize, find the pin, and read its screenshots; refuse a stale `at`. */
57 async function begin(
58 app: App,
59 actor: User | undefined,
60 repo: Repo,
61 label: string,
62 at?: string,
63 ): Promise<Result<Edit, ShotsError>> {
64 const writer = writerOf(app, actor, repo);
65 if (!writer.ok) return writer;
66 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
67 const pin = await readBuildPin(dir, label);
68 if (pin === undefined) return err("not_found");
69 const shots = await readShots(dir, label);
70 // A form names the tree it was drawn from, so a click never lands on a
71 // list someone else has since changed.
72 if (at !== undefined && at !== "" && at !== (shots.tree ?? "")) return err("moved");
73 return ok({ writer: writer.value, dir, pin, shots });
74 }
75
76 /** Write the new list and announce it. */
77 async function finish(
78 app: App,
79 repo: Repo,
80 edit: Edit,
81 entries: readonly ShotEntry[],
82 change: Change,
83 ): Promise<Result<Shots, ShotsError>> {
84 const written = await writeShots(edit.dir, edit.pin.label, edit.shots.tree, entries);
85 if (!written.ok) return written;
86 app.bus.append({
87 kind: "build.shots",
88 repoId: repo.id,
89 actorId: edit.writer.id,
90 payload: { label: edit.pin.label, oid: edit.pin.oid, change, count: written.value.items.length },
91 });
92 return written;
93 }
94
95 const kept = (shots: Shots): ShotEntry[] =>
96 shots.items.map(({ oid, type, caption, size }) => ({ oid, type, caption, size }));
97
98 /** Add an uploaded screenshot at the end. */
99 export async function uploadShot(
100 app: App,
101 actor: User | undefined,
102 repo: Repo,
103 label: string,
104 bytes: Uint8Array,
105 caption: string,
106 at?: string,
107 ): Promise<Result<Shots, ShotsError>> {
108 const edit = await begin(app, actor, repo, label, at);
109 if (!edit.ok) return edit;
110 const said = cleanCaption(caption);
111 if (!isCaption(said)) return err("invalid_caption");
112 if (edit.value.shots.items.length >= SHOTS_LIMIT) return err("too_many");
113 const stored = await storeShot(edit.value.dir, bytes);
114 if (!stored.ok) return stored;
115 return finish(app, repo, edit.value, [...kept(edit.value.shots), { ...stored.value, caption: said }], "added");
116 }
117
118 /**
119 * The pictures a deploy of the pin's commit came up with, as the forge
120 * took them: one per page, width and theme, the newest of each.
121 */
122 export function pickableLooks(app: App, repo: Repo, pin: BuildPin): Look[] {
123 const record = getAppByRepo(app.db, repo.id);
124 if (record === undefined) return [];
125 const seen = new Set<string>();
126 return looksForCommits(app.db, [pin.oid]).filter((l) => {
127 const key = `${l.page}\0${l.kind}\0${l.theme}`;
128 if (l.appId !== record.id || l.commitOid !== pin.oid || l.error !== null || l.kind === "thumb" || l.path === "" || seen.has(key)) return false;
129 seen.add(key);
130 return true;
131 });
132 }
133
134 /** Copy the looks named by `ids` into the pin's screenshots, in the order named. */
135 export async function pickShots(
136 app: App,
137 actor: User | undefined,
138 repo: Repo,
139 label: string,
140 ids: readonly string[],
141 at?: string,
142 ): Promise<Result<Shots, ShotsError>> {
143 const edit = await begin(app, actor, repo, label, at);
144 if (!edit.ok) return edit;
145 const looks = new Map(pickableLooks(app, repo, edit.value.pin).map((l) => [l.id, l]));
146 const picked = [...new Set(ids)].map((id) => looks.get(id)).filter((l): l is Look => l !== undefined);
147 if (picked.length === 0) return err("nothing_picked");
148 if (edit.value.shots.items.length + picked.length > SHOTS_LIMIT) return err("too_many");
149 const added: ShotEntry[] = [];
150 for (const look of picked) {
151 // Read off the event loop: a pick of 12 pictures must not block other requests.
152 const bytes = await Bun.file(join(app.config.dataDir, look.path)).bytes().catch(() => undefined);
153 if (bytes === undefined) return err("no_such_shot");
154 const stored = await storeShot(edit.value.dir, bytes);
155 if (!stored.ok) return stored;
156 added.push({ ...stored.value, caption: "" });
157 }
158 return finish(app, repo, edit.value, [...kept(edit.value.shots), ...added], "picked");
159 }
160
161 /** Give one screenshot a new caption, or move it one place up or down. */
162 export async function editShot(
163 app: App,
164 actor: User | undefined,
165 repo: Repo,
166 label: string,
167 file: string,
168 change: { readonly caption?: string; readonly move?: "up" | "down"; readonly at?: string },
169 ): Promise<Result<Shots, ShotsError>> {
170 const edit = await begin(app, actor, repo, label, change.at);
171 if (!edit.ok) return edit;
172 const list = kept(edit.value.shots);
173 const i = edit.value.shots.items.findIndex((s) => s.file === file);
174 if (i === -1) return err("no_such_shot");
175 if (change.caption !== undefined) {
176 const said = cleanCaption(change.caption);
177 if (!isCaption(said)) return err("invalid_caption");
178 list[i] = { ...list[i]!, caption: said };
179 }
180 if (change.move !== undefined) {
181 const j = change.move === "up" ? i - 1 : i + 1;
182 if (j >= 0 && j < list.length) [list[i], list[j]] = [list[j]!, list[i]!];
183 }
184 return finish(app, repo, edit.value, list, change.caption !== undefined ? "captioned" : "moved");
185 }
186
187 /** Take one screenshot off the pin. */
188 export async function removeShot(
189 app: App,
190 actor: User | undefined,
191 repo: Repo,
192 label: string,
193 file: string,
194 at?: string,
195 ): Promise<Result<Shots, ShotsError>> {
196 const edit = await begin(app, actor, repo, label, at);
197 if (!edit.ok) return edit;
198 const i = edit.value.shots.items.findIndex((s) => s.file === file);
199 if (i === -1) return err("no_such_shot");
200 return finish(app, repo, edit.value, kept(edit.value.shots).filter((_, k) => k !== i), "removed");
201 }
src/actions/builds.ts +1 −1
@@ -39,7 +39,7 @@ export interface PinBuildInput {
3939 export type PinBuildActionError = AccessError | PinBuildError | "mirrored";
4040
4141 /** A person who writes the repository, never an agent, and not on a mirror: who may pin, note or delete. */
42 function writerOf(app: App, actor: User | undefined, repo: Repo): Result<User, AccessError | "mirrored"> {
42 export function writerOf(app: App, actor: User | undefined, repo: Repo): Result<User, AccessError | "mirrored"> {
4343 const writer = requireWriter(app, actor, repo);
4444 if (!writer.ok) return writer;
4545 if (writer.value.kind === "agent") return err("forbidden");
src/contract/openapi/routes/deleteApiV1ReposOwnerNameBuildsLabelShotsFile.ts added +11
@@ -0,0 +1,11 @@
1 import { REPO, SHOT_REFUSALS, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/builds/{label}/shots/{file}`,
6 summary: "Remove one screenshot from a pin (rw scope, a person who writes); `?at=` is the `tree` it was read from; answers 204",
7 auth: true,
8 query: { at: "string" },
9 status: 204,
10 refusals: { ...SHOT_REFUSALS, no_such_shot: "404: the pin has no screenshot by that name" },
11 };
src/contract/openapi/routes/getApiV1ReposOwnerNameBuildsLabelShots.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/builds/{label}/shots`,
6 summary: "List a pin's screenshots (a tree kept by refs/shots/<label>), in display order; answers { items, next_cursor, tree }, each item { file, caption, type, size, url }",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/builds/{label}/shots/looks`,
6 summary: "List the pictures the forge took when a deploy of the pin's commit came up, the ones the caller may see; answers { items, next_cursor }, each item { id, page, kind, theme, env, url }",
7 auth: false,
8 };
src/contract/openapi/routes/index.ts +6
@@ -14,6 +14,7 @@ export { route as deleteApiV1ReposOwnerNameAppDomainsHost } from "./deleteApiV1R
1414 export { route as deleteApiV1ReposOwnerNameAppEnvVar } from "./deleteApiV1ReposOwnerNameAppEnvVar.ts";
1515 export { route as deleteApiV1ReposOwnerNameBranchesBranch } from "./deleteApiV1ReposOwnerNameBranchesBranch.ts";
1616 export { route as deleteApiV1ReposOwnerNameBuildsLabel } from "./deleteApiV1ReposOwnerNameBuildsLabel.ts";
17 export { route as deleteApiV1ReposOwnerNameBuildsLabelShotsFile } from "./deleteApiV1ReposOwnerNameBuildsLabelShotsFile.ts";
1718 export { route as deleteApiV1ReposOwnerNameCrewUsername } from "./deleteApiV1ReposOwnerNameCrewUsername.ts";
1819 export { route as deleteApiV1ReposOwnerNameEgressGrants } from "./deleteApiV1ReposOwnerNameEgressGrants.ts";
1920 export { route as deleteApiV1ReposOwnerNameHooksId } from "./deleteApiV1ReposOwnerNameHooksId.ts";
@@ -98,6 +99,8 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam
9899 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
99100 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
100101 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
102 export { route as getApiV1ReposOwnerNameBuildsLabelShots } from "./getApiV1ReposOwnerNameBuildsLabelShots.ts";
103 export { route as getApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts";
101104 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
102105 export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts";
103106 export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts";
@@ -253,6 +256,9 @@ export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1Repos
253256 export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts";
254257 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
255258 export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts";
259 export { route as postApiV1ReposOwnerNameBuildsLabelShots } from "./postApiV1ReposOwnerNameBuildsLabelShots.ts";
260 export { route as postApiV1ReposOwnerNameBuildsLabelShotsFile } from "./postApiV1ReposOwnerNameBuildsLabelShotsFile.ts";
261 export { route as postApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./postApiV1ReposOwnerNameBuildsLabelShotsLooks.ts";
256262 export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts";
257263 export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts";
258264 export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameBuildsLabelShots.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, SHOT_REFUSALS, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/builds/{label}/shots`,
6 summary: "Upload a screenshot to a pin (rw scope, a person who writes): multipart/form-data with the picture as `shot` (PNG, JPEG or WebP by its first bytes, at most 2 MB) and an optional `caption` (one line, at most 140 characters); answers 201 with the list",
7 auth: true,
8 status: 201,
9 refusals: { ...SHOT_REFUSALS, invalid_shot: "422: not a PNG, JPEG or WebP picture", too_large: "413: over 2 MB", too_many: "409: the pin holds 12 screenshots", invalid_caption: "422: more than one line or 140 characters" },
10 };
src/contract/openapi/routes/postApiV1ReposOwnerNameBuildsLabelShotsFile.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, SHOT_REFUSALS, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/builds/{label}/shots/{file}`,
6 summary: "Edit one screenshot (rw scope, a person who writes): a new `caption`, and/or `move` up or down one place; `at` is the `tree` it was read from; answers the list",
7 auth: true,
8 body: { caption: "string", move: "string", at: "string" },
9 refusals: { ...SHOT_REFUSALS, no_such_shot: "404: the pin has no screenshot by that name", invalid_caption: "422: more than one line or 140 characters", invalid_move: "422: move is up or down" },
10 };
src/contract/openapi/routes/postApiV1ReposOwnerNameBuildsLabelShotsLooks.ts added +11
@@ -0,0 +1,11 @@
1 import { REPO, SHOT_REFUSALS, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/builds/{label}/shots/looks`,
6 summary: "Copy deploy pictures into a pin's screenshots, in the order named (rw scope, a person who writes); answers 201 with the list",
7 auth: true,
8 body: { looks: "array" },
9 status: 201,
10 refusals: { ...SHOT_REFUSALS, nothing_picked: "422: no id names a picture of the pin's commit", too_many: "409: more than 12 screenshots", too_large: "413: a picture is over 2 MB" },
11 };
src/contract/openapi/shared.ts +3
@@ -64,3 +64,6 @@ export const BROWSE_CODE_REFUSALS = {
6464 ...BROWSE_REFUSALS,
6565 not_found: "404: no such origin of yours, or the origin has no such repository, ref or path",
6666 };
67
68 /** The refusals every write to a pin's screenshots answers (`actions/build_shots.ts`). */
69 export const SHOT_REFUSALS = { forbidden: "403: not a person who writes the repository (an agent is refused)", not_found: "404: no such repository or pin, or the caller cannot read it", mirrored: "409: the repository mirrors an origin", moved: "409: the screenshots changed since `at` was read", git_failed: "422: git refused the change" };
src/core/event_audience.ts +2
@@ -135,6 +135,8 @@ const READER_KINDS: ReadonlySet<string> = new Set([
135135 "build.pinned",
136136 "build.deleted",
137137 "build.noted",
138 // A pin's screenshots are read by its repository's readers (`actions/build_shots.ts`).
139 "build.shots",
138140 "invite.used",
139141 "user.updated",
140142 "user.key_added",
src/core/looks.ts +22
@@ -255,6 +255,28 @@ export function looksForApps(db: Database, appIds: readonly string[]): Look[] {
255255 return out;
256256 }
257257
258 /**
259 * Every picture taken of some commits, in any environment and either
260 * theme, newest first: what a pin's screenshots are picked from. Errors
261 * are left out; a person picks only what is there.
262 */
263 export function looksForCommits(db: Database, commitOids: readonly string[]): Look[] {
264 const out: Look[] = [];
265 const unique = [...new Set(commitOids)];
266 const CHUNK = 400;
267 for (let i = 0; i < unique.length; i += CHUNK) {
268 const batch = unique.slice(i, i + CHUNK);
269 const rows = db
270 .query<Row, string[]>(
271 `SELECT * FROM look WHERE error IS NULL AND commit_oid IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
272 ORDER BY created_at DESC, kind, theme`,
273 )
274 .all(...batch);
275 for (const row of rows) out.push(toLook(row));
276 }
277 return out;
278 }
279
258280 /**
259281 * Forget an environment's looks — all of them, or all but one commit's
260282 * (a person's attachments are not the forge's to age out: they go with the
src/git/builds.ts +12
@@ -398,6 +398,15 @@ export async function notePinBuild(
398398 return ok({ pin: withNotes(pin, notes), note, commit: written.value });
399399 }
400400
401 /** Whether `bytes` start like a PNG, JPEG or WebP picture. */
402 export function coverType(bytes: Uint8Array): "image/png" | "image/jpeg" | "image/webp" | undefined {
403 const at = (i: number, ...want: number[]) => want.every((b, k) => bytes[i + k] === b);
404 if (at(0, 0x89, 0x50, 0x4e, 0x47)) return "image/png";
405 if (at(0, 0xff, 0xd8, 0xff)) return "image/jpeg";
406 if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return "image/webp";
407 return undefined;
408 }
409
401410 export type DeletePinError = "not_found" | "moved" | "git_failed";
402411
403412 /**
@@ -416,6 +425,9 @@ export async function deletePin(dir: string, label: string): Promise<Result<{ re
416425 if (tag === undefined) return err("not_found");
417426 const commit = await runGitCapture(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`], { cwd: dir });
418427 const lines = [`delete ${ref} ${tag}`];
428 // The pin's curated screenshots (`git/shots.ts`) go with it, so a label pinned again starts bare.
429 const shots = await read(`refs/shots/${label}`);
430 if (shots !== undefined) lines.push(`delete refs/shots/${label} ${shots}`);
419431 const done = await runGitCapture(["update-ref", "--stdin"], { cwd: dir, stdin: `start\n${lines.join("\n")}\nprepare\ncommit\n` });
420432 if (done.code !== 0) return err(/cannot lock|is at|expected/i.test(done.stderr) ? "moved" : "git_failed");
421433 return ok({ oid: commit.stdout.trim(), tag });
src/git/shots.ts added +180
@@ -0,0 +1,180 @@
1 /**
2 * A pin's screenshots: curated pictures that ride with a build, for a
3 * feature too costly to stand up as a running preview. They live in git
4 * as one tree kept by `refs/shots/<label>`, beside `refs/covers/<label>`:
5 * the pictures, named `01.png`, `02.jpg`, … in the order shown, and a
6 * `shots.json` that lists them with their captions. Every change writes a
7 * new tree and moves the ref from the one it read, so two edits never
8 * cross. Nothing lives in the database.
9 *
10 * The ref can be pushed like any other, so what it holds is read as
11 * untrusted: a file the list names must be a picture by its first bytes,
12 * named as this module names them, and no larger than a person may upload.
13 */
14
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import { coverType, isPinLabel } from "./builds.ts";
17 import { runGit, runGitCapture, runGitRaw } from "./env.ts";
18 import { isSafeOid } from "./guard.ts";
19
20 export const SHOTS_PREFIX = "refs/shots/";
21
22 /** The largest screenshot a person may add. */
23 export const SHOT_MAX = 2 * 1024 * 1024;
24 /** How many screenshots one pin may carry. */
25 export const SHOTS_LIMIT = 12;
26 /** The longest caption. */
27 export const CAPTION_MAX = 140;
28 /** The list's own name in the tree. */
29 const LIST = "shots.json";
30
31 export type ShotType = "image/png" | "image/jpeg" | "image/webp";
32 const EXT: Record<ShotType, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
33 const TYPE_OF: Record<string, ShotType> = { png: "image/png", jpg: "image/jpeg", webp: "image/webp" };
34
35 /** One screenshot as the tree holds it. */
36 export interface Shot {
37 /** Its name in the tree: its place in the order and its kind. */
38 readonly file: string;
39 /** One plain line; may be empty. */
40 readonly caption: string;
41 /** The blob it is. */
42 readonly oid: string;
43 readonly type: ShotType;
44 readonly size: number;
45 }
46
47 /** A pin's screenshots and the tree they were read from. */
48 export interface Shots {
49 /** The tree the ref points at, or undefined when the pin has none. */
50 readonly tree: string | undefined;
51 readonly items: readonly Shot[];
52 }
53
54 /** What a write keeps: a stored picture and its caption, in order. */
55 export type ShotEntry = Omit<Shot, "file">;
56
57 /** A name this module gives a screenshot. */
58 export const isShotFile = (file: string): boolean => /^\d{2}\.(?:png|jpg|webp)$/.test(file);
59
60 /** A caption: one plain line, at most `CAPTION_MAX` characters, maybe empty. */
61 export const isCaption = (caption: string): boolean =>
62 caption.length <= CAPTION_MAX && !/[\x00-\x1f\x7f]/.test(caption) && caption.trim() === caption;
63
64 /** A caption as a person typed it: line breaks folded, ends trimmed. */
65 export const cleanCaption = (caption: string): string => caption.replace(/[\r\n\t]+/g, " ").trim();
66
67 /** The ref of one pin's screenshots. */
68 const refOf = (label: string): string => `${SHOTS_PREFIX}${label}`;
69
70 /** A pin's screenshots, read from its tree; none when it has no tree. */
71 export async function readShots(dir: string, label: string): Promise<Shots> {
72 const none: Shots = { tree: undefined, items: [] };
73 if (!isPinLabel(label)) return none;
74 const at = await runGitCapture(["rev-parse", "--verify", "--quiet", refOf(label)], { cwd: dir });
75 const tree = at.stdout.trim();
76 if (at.code !== 0 || !isSafeOid(tree)) return none;
77 const kind = await runGitCapture(["cat-file", "-t", tree], { cwd: dir });
78 if (kind.code !== 0 || kind.stdout.trim() !== "tree") return none;
79 const listed = await runGit(["ls-tree", "-l", "-z", tree], { cwd: dir });
80 if (!listed.ok) return { tree, items: [] };
81 // `<mode> <type> <oid> <size>\t<name>`, one per NUL.
82 const blobs = new Map<string, { oid: string; size: number }>();
83 for (const line of listed.value.split("\0")) {
84 const m = /^100644 blob ([0-9a-f]{40,64}) +(\d+)\t(.+)$/.exec(line);
85 if (m !== null) blobs.set(m[3]!, { oid: m[1]!, size: Number(m[2]) });
86 }
87 const list = blobs.get(LIST);
88 if (list === undefined || list.size > 64 * 1024) return { tree, items: [] };
89 const text = await runGit(["cat-file", "blob", list.oid], { cwd: dir });
90 if (!text.ok) return { tree, items: [] };
91 let raw: unknown;
92 try {
93 raw = JSON.parse(text.value);
94 } catch {
95 return { tree, items: [] };
96 }
97 if (!Array.isArray(raw)) return { tree, items: [] };
98 const items: Shot[] = [];
99 const seen = new Set<string>();
100 for (const entry of raw as unknown[]) {
101 if (items.length >= SHOTS_LIMIT) break;
102 if (typeof entry !== "object" || entry === null) continue;
103 const { file, caption } = entry as { file?: unknown; caption?: unknown };
104 if (typeof file !== "string" || !isShotFile(file) || seen.has(file)) continue;
105 const blob = blobs.get(file);
106 if (blob === undefined || blob.size > SHOT_MAX) continue;
107 seen.add(file);
108 items.push({
109 file,
110 caption: typeof caption === "string" && isCaption(caption) ? caption : "",
111 oid: blob.oid,
112 type: TYPE_OF[file.slice(3)]!,
113 size: blob.size,
114 });
115 }
116 return { tree, items };
117 }
118
119 /** One screenshot's bytes and kind, by its first bytes; undefined when it is not a picture. */
120 export async function readShotBytes(
121 dir: string,
122 label: string,
123 file: string,
124 ): Promise<{ readonly bytes: Uint8Array; readonly type: ShotType; readonly oid: string } | undefined> {
125 if (!isShotFile(file)) return undefined;
126 const shot = (await readShots(dir, label)).items.find((s) => s.file === file);
127 if (shot === undefined) return undefined;
128 const bytes = await runGitRaw(["cat-file", "blob", shot.oid], { cwd: dir });
129 if (!bytes.ok) return undefined;
130 const type = coverType(bytes.value);
131 return type === undefined ? undefined : { bytes: bytes.value, type, oid: shot.oid };
132 }
133
134 /** Store a picture as a blob: refused unless it is a PNG, JPEG or WebP by its first bytes, within `SHOT_MAX`. */
135 export async function storeShot(dir: string, bytes: Uint8Array): Promise<Result<{ oid: string; type: ShotType; size: number }, "invalid_shot" | "too_large" | "git_failed">> {
136 const type = coverType(bytes);
137 if (type === undefined) return err("invalid_shot");
138 if (bytes.length > SHOT_MAX) return err("too_large");
139 const blob = await runGit(["hash-object", "-w", "--stdin"], { cwd: dir, stdin: bytes });
140 if (!blob.ok) return err("git_failed");
141 return ok({ oid: blob.value.trim(), type, size: bytes.length });
142 }
143
144 export type WriteShotsError = "too_many" | "invalid_caption" | "moved" | "git_failed";
145
146 /**
147 * Keep `entries`, in order, as the pin's screenshots: a new tree, and the
148 * ref moved to it only from `from` (undefined: only when there is none).
149 * No entries removes the ref.
150 */
151 export async function writeShots(
152 dir: string,
153 label: string,
154 from: string | undefined,
155 entries: readonly ShotEntry[],
156 ): Promise<Result<Shots, WriteShotsError>> {
157 if (!isPinLabel(label)) return err("git_failed");
158 if (entries.length > SHOTS_LIMIT) return err("too_many");
159 if (entries.some((e) => !isCaption(e.caption) || !isSafeOid(e.oid))) return err("invalid_caption");
160 if (from !== undefined && !isSafeOid(from)) return err("moved");
161 if (entries.length === 0) {
162 if (from === undefined) return ok({ tree: undefined, items: [] });
163 const gone = await runGitCapture(["update-ref", "-d", refOf(label), from], { cwd: dir });
164 return gone.code === 0 ? ok({ tree: undefined, items: [] }) : err("moved");
165 }
166 const named = entries.map((e, i) => ({ ...e, file: `${String(i + 1).padStart(2, "0")}.${EXT[e.type]}` }));
167 const list = `${JSON.stringify(named.map((e) => ({ file: e.file, caption: e.caption })), null, 2)}\n`;
168 const listBlob = await runGit(["hash-object", "-w", "--stdin"], { cwd: dir, stdin: list });
169 if (!listBlob.ok) return err("git_failed");
170 const lines = [
171 ...named.map((e) => `100644 blob ${e.oid}\t${e.file}\n`),
172 `100644 blob ${listBlob.value.trim()}\t${LIST}\n`,
173 ];
174 const made = await runGit(["mktree"], { cwd: dir, stdin: lines.join("") });
175 if (!made.ok) return err("git_failed");
176 const tree = made.value.trim();
177 const moved = await runGitCapture(["update-ref", refOf(label), tree, from ?? ""], { cwd: dir });
178 if (moved.code !== 0) return err("moved");
179 return ok({ tree, items: named });
180 }
src/http/api.ts +2
@@ -353,6 +353,7 @@ import { forgeOriginRoutes } from "./api/forge_origin.ts";
353353 import { userRoutes } from "./api/user.ts";
354354 import { appRoutes } from "./api/app.ts";
355355 import { buildRoutes } from "./api/builds.ts";
356 import { buildShotRoutes } from "./api/build_shots.ts";
356357 import { iterationRoutes } from "./api/iterations.ts";
357358 import { appNoteRoutes } from "./api/app_notes.ts";
358359 import { reportRoutes } from "./api/reports.ts";
@@ -3633,6 +3634,7 @@ export function apiRoutes(router: Router<Handler>): void {
36333634
36343635 appRoutes(router);
36353636 buildRoutes(router);
3637 buildShotRoutes(router);
36363638 iterationRoutes(router);
36373639
36383640 egressGrantRoutes(router);
src/http/api/build_shots.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * A pin's screenshots over the API: a tree kept by `refs/shots/<label>`
3 * (`git/shots.ts`), changed through `actions/build_shots.ts` by a person
4 * who writes the repository. Reading is the repository's readers'; every
5 * write needs the `rw` scope.
6 */
7
8 import { editShot, pickableLooks, pickShots, removeShot, uploadShot, type ShotsError } from "../../actions/build_shots.ts";
9 import { getAppByRepo } from "../../core/apps.ts";
10 import { lookUrl } from "../../core/looks.ts";
11 import type { Repo } from "../../core/repos.ts";
12 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
13 import { readBuildPin } from "../../git/builds.ts";
14 import { repoDir } from "../../git/repos.ts";
15 import { readShots, type Shots } from "../../git/shots.ts";
16 import { mayFetchLook } from "../../web/pages/looks.ts";
17 import { loadRepo } from "../api.ts";
18 import type { Handler } from "../context.ts";
19 import { readBounded } from "../limits.ts";
20 import { apiError, body, json, notFound, requireScope, respondAccess } from "../respond.ts";
21 import type { Router } from "../router.ts";
22
23 const UPLOAD_MAX = 3 * 1024 * 1024;
24 const STATUS: Record<string, number> = { too_large: 413, moved: 409, too_many: 409, mirrored: 409, no_such_shot: 404 };
25
26 const items = (repo: Repo, label: string, shots: Shots) => ({
27 items: shots.items.map((s) => ({
28 file: s.file,
29 caption: s.caption,
30 type: s.type,
31 size: s.size,
32 url: `/${repo.ownerName}/${repo.name}/builds/${encodeURIComponent(label)}/shots/${s.file}?v=${s.oid.slice(0, 12)}`,
33 })),
34 next_cursor: null,
35 tree: shots.tree ?? null,
36 });
37
38 const answer = (repo: Repo, label: string, done: { ok: true; value: Shots } | { ok: false; error: ShotsError }, status = 200) =>
39 done.ok ? json(items(repo, label, done.value), status) : respondAccess(done.error, STATUS[done.error] ?? 422);
40
41 export function buildShotRoutes(router: Router<Handler>): void {
42 const dirOf = (repo: Repo, root: string) => repoDir(root, repo.ownerName, repo.name);
43
44 router.add("GET", "/api/v1/repos/:owner/:name/builds/:label/shots", async (ctx) => {
45 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
46 const repo = loadRepo(ctx);
47 if (!repo) return notFound();
48 const label = ctx.params.label ?? "";
49 const dir = dirOf(repo, ctx.app.reposRoot);
50 if ((await readBuildPin(dir, label)) === undefined) return notFound();
51 return json(items(repo, label, await readShots(dir, label)));
52 });
53
54 // Upload one: multipart, the picture as `shot`, an optional `caption` and the `tree` it was read from as `at`.
55 router.add("POST", "/api/v1/repos/:owner/:name/builds/:label/shots", async (ctx) => {
56 const denied = requireScope(ctx, "rw");
57 if (denied) return denied;
58 const repo = loadRepo(ctx);
59 if (!repo) return notFound();
60 const bytes = await readBounded(ctx.request, UPLOAD_MAX);
61 if (bytes === "too_large") return apiError("too_large", 413, "a screenshot is at most 2 MB");
62 let form: Awaited<ReturnType<Request["formData"]>>;
63 try {
64 form = await new Response(bytes, { headers: { "content-type": ctx.request.headers.get("content-type") ?? "" } }).formData();
65 } catch {
66 return apiError("invalid_shot", 422, "send multipart/form-data with the picture as `shot`");
67 }
68 const file = form.get("shot");
69 const picture = typeof file === "string" || file === null ? new Uint8Array() : new Uint8Array(await file.arrayBuffer());
70 const caption = form.get("caption");
71 const label = ctx.params.label ?? "";
72 const at = form.get("at");
73 const done = await uploadShot(ctx.app, ctx.auth.user, repo, label, picture, typeof caption === "string" ? caption : "", typeof at === "string" ? at : undefined);
74 return answer(repo, label, done, 201);
75 });
76
77 // What a deploy of the pin's commit was pictured as: what may be picked.
78 router.add("GET", "/api/v1/repos/:owner/:name/builds/:label/shots/looks", async (ctx) => {
79 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
80 const repo = loadRepo(ctx);
81 if (!repo) return notFound();
82 const pin = await readBuildPin(dirOf(repo, ctx.app.reposRoot), ctx.params.label ?? "");
83 if (pin === undefined) return notFound();
84 const record = getAppByRepo(ctx.app.db, repo.id);
85 const base = `/${repo.ownerName}/${repo.name}`;
86 const looks = record === undefined ? [] : pickableLooks(ctx.app, repo, pin).filter((l) => mayFetchLook(ctx, repo, record, l));
87 return json({
88 items: looks.map((l) => ({ id: l.id, page: l.page, kind: l.kind, theme: l.theme, env: l.env, url: lookUrl(base, l) })),
89 next_cursor: null,
90 });
91 });
92
93 // Pick: `{ looks: [id, …], at }`, copied in, in that order.
94 router.add("POST", "/api/v1/repos/:owner/:name/builds/:label/shots/looks", async (ctx) => {
95 const denied = requireScope(ctx, "rw");
96 if (denied) return denied;
97 const repo = loadRepo(ctx);
98 if (!repo) return notFound();
99 const input = await body(ctx.request);
100 const ids = Array.isArray(input.looks) ? input.looks.filter((v): v is string => typeof v === "string") : [];
101 const label = ctx.params.label ?? "";
102 return answer(repo, label, await pickShots(ctx.app, ctx.auth.user, repo, label, ids, typeof input.at === "string" ? input.at : undefined), 201);
103 });
104
105 // Edit one: `{ caption }` and/or `{ move: "up" | "down" }`, with the `tree` it was read from as `at`.
106 router.add("POST", "/api/v1/repos/:owner/:name/builds/:label/shots/:file", async (ctx) => {
107 const denied = requireScope(ctx, "rw");
108 if (denied) return denied;
109 const repo = loadRepo(ctx);
110 if (!repo) return notFound();
111 const input = await body(ctx.request);
112 const move = input.move === "up" || input.move === "down" ? input.move : undefined;
113 if (input.move !== undefined && move === undefined) return apiError("invalid_move", 422, "move is up or down");
114 const label = ctx.params.label ?? "";
115 const done = await editShot(ctx.app, ctx.auth.user, repo, label, ctx.params.file ?? "", {
116 ...(typeof input.caption === "string" ? { caption: input.caption } : {}),
117 ...(move === undefined ? {} : { move }),
118 ...(typeof input.at === "string" ? { at: input.at } : {}),
119 });
120 return answer(repo, label, done);
121 });
122
123 router.add("DELETE", "/api/v1/repos/:owner/:name/builds/:label/shots/:file", async (ctx) => {
124 const denied = requireScope(ctx, "rw");
125 if (denied) return denied;
126 const repo = loadRepo(ctx);
127 if (!repo) return notFound();
128 const label = ctx.params.label ?? "";
129 const at = ctx.url.searchParams.get("at") ?? undefined;
130 const done = await removeShot(ctx.app, ctx.auth.user, repo, label, ctx.params.file ?? "", at);
131 return done.ok ? new Response(null, { status: 204 }) : respondAccess(done.error, STATUS[done.error] ?? 422);
132 });
133 }
src/http/server.ts +3
@@ -185,6 +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";
188189 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
189190 import {
190191 contributeSubmit,
@@ -1260,6 +1261,8 @@ export function buildRouter(): Router<Handler> {
12601261 // Build pins (`git/builds.ts`): the list, the form, and the writes behind them.
12611262 router.add("GET", "/:owner/:name/builds", buildsPage);
12621263 router.add("GET", "/:owner/:name/builds/new", newBuildPage);
1264 // A pin's screenshot, read: a tree kept by refs/shots/<label> (`git/shots.ts`).
1265 router.add("GET", "/:owner/:name/builds/:label/shots/:file", shotImage);
12631266 router.add("GET", "/:owner/:name/iterations", iterationsPage);
12641267 router.add("POST", "/:owner/:name/builds", pinBuildSubmit);
12651268 router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit);
src/tui/screens/builds.ts +4 −1
@@ -20,6 +20,7 @@ import type { Command } from "../app/palette.ts";
2020 import type { Session } from "../session.ts";
2121 import { style } from "../term/style.ts";
2222 import { truncate } from "../term/width.ts";
23 import { shotsScreen } from "./shots.ts";
2324 import { ListState, ago, confirmScreen, metaOf, promptScreen } from "./common.ts";
2425
2526 export const NOT_FOUND_LINE = "No such repository";
@@ -113,6 +114,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
113114 get keys() {
114115 return [
115116 binding(["j", "k"], "move"),
117 binding(["s"], "screenshots"),
116118 ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["d"], "delete")] : []),
117119 binding(["esc", "q"], "back"),
118120 ];
@@ -150,6 +152,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
150152 if (e.key === "j" || e.key === "down") state.move(1, pins?.length ?? 0);
151153 else if (e.key === "k" || e.key === "up") state.move(-1, pins?.length ?? 0);
152154 else if (e.key === "esc" || e.key === "q") ctx.pop();
155 else if (e.key === "s" && !hidden && picked !== undefined) ctx.push(shotsScreen(f, ctx, repo, picked));
153156 else if (e.key === "p" && write) void ctx.track(pin());
154157 else if (e.key === "n" && write && picked !== undefined) addNote(picked);
155158 else if (e.key === "d" && write && picked !== undefined) ask(picked);
@@ -168,7 +171,7 @@ export function buildsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Co
168171 {
169172 id: "builds",
170173 title: `${repo.ownerName}/${repo.name} — Builds`,
171 hint: "its pinned builds; a writer pins, notes and deletes",
174 hint: "its pinned builds; `s` shows a pin's screenshots; a writer pins, notes and deletes",
172175 run: () => ctx.push(buildsScreen(f, ctx, repo)),
173176 },
174177 ];
src/tui/screens/shots.ts added +145
@@ -0,0 +1,145 @@
1 /**
2 * A pin's screenshots, in the terminal: the list for whoever may read the
3 * repository, and for a person who writes it (never an agent) `p` pick from
4 * the pictures the forge took of the pin's commit, `c` caption, `u`/`d` move
5 * up or down, `x` remove — each through `actions/build_shots.ts`, which
6 * checks again and emits `build.shots` for the web. Upload is the API's: a
7 * terminal has no file of the person's to send, only the server's own.
8 */
9
10 import { editShot, pickableLooks, pickShots, removeShot } from "../../actions/build_shots.ts";
11 import { getAppByRepo } from "../../core/apps.ts";
12 import { canRead, type Repo } from "../../core/repos.ts";
13 import { readBuildPin, type BuildPin } from "../../git/builds.ts";
14 import { repoDir } from "../../git/repos.ts";
15 import { readShots, type Shots } from "../../git/shots.ts";
16 import { mayFetchLook } from "../../looks/readable.ts";
17 import type { Ctx, Screen } from "../app/app.ts";
18 import { binding } from "../app/keys.ts";
19 import type { Session } from "../session.ts";
20 import { style } from "../term/style.ts";
21 import { truncate } from "../term/width.ts";
22 import { ListState, confirmScreen, metaOf, pickerScreen, promptScreen } from "./common.ts";
23 import { mayPin } from "./builds.ts";
24
25 export const NO_SHOTS_LINE = "No screenshots kept with this pin yet.";
26
27 const WORDS: Record<string, string> = {
28 invalid_caption: "A caption is one line, at most 140 characters.",
29 too_many: "A pin keeps at most 12 screenshots.",
30 nothing_picked: "Pick at least one picture.",
31 no_such_shot: "That screenshot is gone.",
32 not_found: "That pin is gone.",
33 moved: "The screenshots changed meanwhile; try again.",
34 mirrored: "A mirror's builds are its origin's.",
35 too_large: "A screenshot is at most 2 MB.",
36 };
37 const say = (error: string): string => WORDS[error] ?? "Only a person who writes this repository can do that.";
38
39 export function shotsScreen(f: Session, ctx: Ctx, repo: Repo, pin: BuildPin): Screen {
40 const dir = repoDir(f.app.reposRoot, repo.ownerName, repo.name);
41 let shots: Shots | undefined;
42 let hidden = false;
43 let note = "";
44 const state = new ListState();
45 const reload = async (): Promise<void> => {
46 shots = await readShots(dir, pin.label);
47 state.clamp(shots.items.length);
48 ctx.invalidate();
49 };
50 const done = async (line: string): Promise<void> => {
51 note = line;
52 await reload();
53 };
54 const pickable = () => {
55 const record = getAppByRepo(f.app.db, repo.id);
56 return record === undefined ? [] : pickableLooks(f.app, repo, pin).filter((l) => mayFetchLook(f.app, repo, record, l, f.user));
57 };
58
59 const screen: Screen = {
60 title: `Screenshots · ${pin.label}`,
61 get keys() {
62 return [
63 binding(["j", "k"], "move"),
64 ...(mayPin(f, repo) ? [binding(["p"], "pick"), binding(["c"], "caption"), binding(["u", "d"], "move up/down"), binding(["x"], "remove")] : []),
65 binding(["esc", "q"], "back"),
66 ];
67 },
68 async load() {
69 hidden = !canRead(f.db, repo, f.user) || (await readBuildPin(dir, pin.label)) === undefined;
70 if (hidden) shots = { tree: undefined, items: [] };
71 else await reload();
72 ctx.invalidate();
73 },
74 render(area, s) {
75 const c = ctx.theme.c;
76 const right = area.x + area.w;
77 const line = (y: number, text: string, fg = c.fg) => s.text(area.x + 1, area.y + y, truncate(text, area.w - 1), style(fg, c.bg), right);
78 if (hidden) return void line(0, "No such pin");
79 if (shots === undefined) return void line(0, "Reading…");
80 const rows = note === "" ? area.h : Math.max(1, area.h - 1);
81 if (note !== "") line(area.h - 1, note, c.muted);
82 if (shots.items.length === 0) return void line(0, NO_SHOTS_LINE);
83 const from = state.window(rows, shots.items.length);
84 for (let i = 0; i < rows; i++) {
85 const shot = shots.items[from + i];
86 if (shot === undefined) break;
87 const picked = from + i === state.index;
88 s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right);
89 line(i, `${shot.file} ${metaOf(shot.type.slice(6), `${Math.ceil(shot.size / 1024)} KB`, shot.caption)}`, picked ? c.fg : c.muted);
90 }
91 },
92 key(e) {
93 note = "";
94 const count = shots?.items.length ?? 0;
95 const current = shots?.items[state.index];
96 const write = !e.ctrl && !e.alt && !hidden && mayPin(f, repo);
97 const act = (p: Promise<{ readonly ok: boolean; readonly error?: string }>, ok: string): void =>
98 void ctx.track(p.then((r) => done(r.ok ? ok : say(r.error ?? ""))));
99 if (e.key === "j" || e.key === "down") state.move(1, count);
100 else if (e.key === "k" || e.key === "up") state.move(-1, count);
101 else if (e.key === "esc" || e.key === "q") ctx.pop();
102 else if (e.key === "p" && write) {
103 const looks = pickable();
104 if (looks.length === 0) note = "The forge took no pictures of this pin's commit.";
105 else
106 ctx.push(
107 pickerScreen(ctx, screen, {
108 title: "Pick pictures",
109 multi: true,
110 items: looks.map((l) => ({ id: l.id, label: `${l.page} · ${l.kind} · ${l.theme}`, hint: l.env })),
111 onPick: (ids) => act(pickShots(f.app, f.user, repo, pin.label, ids), `Kept ${ids.length}.`),
112 }),
113 );
114 } else if (e.key === "c" && write && current !== undefined) {
115 ctx.push(
116 promptScreen(ctx, screen, {
117 title: `Caption ${current.file}`,
118 label: "one line, at most 140 characters",
119 value: current.caption,
120 onSubmit: (text) =>
121 ctx.track(editShot(f.app, f.user, repo, pin.label, current.file, { caption: text, at: shots?.tree ?? "" }).then((r) => done(r.ok ? "Captioned." : say(r.error)))),
122 }),
123 );
124 } else if ((e.key === "u" || e.key === "d") && write && current !== undefined) {
125 const move = e.key === "u" ? "up" : "down";
126 state.move(move === "up" ? -1 : 1, count);
127 act(editShot(f.app, f.user, repo, pin.label, current.file, { move, at: shots?.tree ?? "" }), "Moved.");
128 } else if (e.key === "x" && write && current !== undefined) {
129 ctx.push(
130 confirmScreen(ctx, screen, {
131 title: "Remove a screenshot",
132 body: [`Remove ${current.file} from ${pin.label}?`],
133 actions: [{ label: "Keep", variant: "ghost" }, { label: "Remove", variant: "danger" }],
134 onChoose: (i) => {
135 if (i >= 1) act(removeShot(f.app, f.user, repo, pin.label, current.file, shots?.tree ?? ""), "Removed.");
136 },
137 }),
138 );
139 } else return false;
140 ctx.invalidate();
141 return true;
142 },
143 };
144 return screen;
145 }
src/web/pages/build_shots.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * A pin's screenshots, read: one picture by name, to whoever reads the
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.
5 */
6
7 import { readShotBytes } from "../../git/shots.ts";
8 import { repoDir } from "../../git/repos.ts";
9 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { loadRepo, notFoundPage } from "../chrome.ts";
12
13 // GET /:owner/:name/builds/:label/shots/:file — one screenshot. Typed by
14 // its first bytes, never by its name, and never sniffed into anything else.
15 export async function shotImage(ctx: Ctx): Promise<Response> {
16 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
17 const repo = loadRepo(ctx);
18 if (!repo) return notFoundPage(ctx);
19 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
20 const shot = await timePhase(ctx.request, "read", () => readShotBytes(dir, ctx.params.label ?? "", ctx.params.file ?? ""));
21 if (shot === undefined) return notFoundPage(ctx);
22 // A name moves to another picture on a reorder; the blob it is does not.
23 const etag = `"${shot.oid}"`;
24 const headers = {
25 "content-type": shot.type,
26 "x-content-type-options": "nosniff",
27 "content-security-policy": "default-src 'none'; sandbox",
28 etag,
29 "cache-control":
30 ctx.url.searchParams.get("v") === shot.oid.slice(0, 12) ? "private, max-age=31536000, immutable" : "private, no-cache",
31 };
32 if (ctx.request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
33 return new Response(shot.bytes, { headers });
34 }
test/build-shots.test.ts added +298
@@ -0,0 +1,298 @@
1 /**
2 * A pin's screenshots: a writer uploads them, picks them from the deploy's
3 * pictures, orders, captions and removes them through the API; the web
4 * only shows each picture. Git holds them as a tree kept by refs/shots/<label>. A picture is
5 * one by its first bytes and within its limits; a stranger to a private
6 * repository sees nothing, and a reader, a stranger and an agent change
7 * nothing.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdirSync, writeFileSync } from "node:fs";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { deletePin, pinBuild } from "../src/actions/builds.ts";
16 import { pickShots, uploadShot } from "../src/actions/build_shots.ts";
17 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
19 import { createApp as insertApp } from "../src/core/apps.ts";
20 import { recordLook, type Look } from "../src/core/looks.ts";
21 import type { Repo } from "../src/core/repos.ts";
22 import { createToken } from "../src/core/tokens.ts";
23 import { createUser, getUserByName, type User } from "../src/core/users.ts";
24 import { repoDir } from "../src/git/repos.ts";
25 import { readShots, SHOT_MAX } from "../src/git/shots.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27 import { gitClientEnv } from "./helpers/git-client.ts";
28
29 const PNG = (n: number) => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, n, n]);
30 const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
31 const WEBP = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 9]);
32 const PAGE = new TextEncoder().encode("<script>alert(1)</script>");
33
34 describe("pin screenshots", () => {
35 let dataDir: string;
36 let app: App;
37 let servers: Servers;
38 let base: string;
39 let alice: User;
40 let repo: Repo;
41 let secret: Repo;
42 let oid: string;
43 let desktop: Look;
44 let phone: Look;
45 let elsewhere: Look;
46 let aliceToken: string;
47 let bobToken: string;
48 const cookies: Record<string, string> = {};
49 const dir = () => repoDir(app.reposRoot, "alice", "karts");
50 const shots = () => readShots(dir(), "v1");
51
52 const signIn = async (username: string) =>
53 (
54 (
55 await fetch(`${base}/signin`, {
56 method: "POST",
57 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
58 redirect: "manual",
59 })
60 ).headers.get("set-cookie") ?? ""
61 ).split(";")[0]!;
62 const get = (path: string, who?: string) =>
63 fetch(`${base}${path}`, { headers: who === undefined ? {} : { cookie: cookies[who] ?? "" }, redirect: "manual" });
64 const api = (method: string, path: string, token: string, body?: string | FormData, type?: string) =>
65 fetch(`${base}/api/v1/repos${path}`, {
66 method,
67 headers: { authorization: `Bearer ${token}`, ...(type === undefined ? {} : { "content-type": type }) },
68 ...(body === undefined ? {} : { body }),
69 });
70 const upload = (bytes: Uint8Array, caption: string, name = "shot.png") => {
71 const form = new FormData();
72 form.append("shot", new Blob([bytes], { type: "image/png" }), name);
73 form.append("caption", caption);
74 return form;
75 };
76 /** A picture the forge took of `commit`, on disk where the looks service keeps them. */
77 const look = (appId: string, commit: string, kind: "phone" | "desktop", bytes: Uint8Array): Look => {
78 const path = join("looks", appId, "production", `${commit}-${kind}.png`);
79 mkdirSync(join(dataDir, "looks", appId, "production"), { recursive: true });
80 writeFileSync(join(dataDir, path), bytes);
81 return recordLook(app.db, { appId, env: "production", commitOid: commit, kind, path });
82 };
83 const git = (...args: string[]) => {
84 const r = Bun.spawnSync(["git", ...args], { cwd: dir(), env: gitClientEnv() });
85 return new TextDecoder().decode(r.stdout).trim();
86 };
87
88 beforeAll(async () => {
89 dataDir = await mkdtemp(join(tmpdir(), "forge-shots-"));
90 app = createApp({ dataDir, port: 0 });
91 servers = await serve(app);
92 base = servers.url;
93 for (const name of ["alice", "bob"]) {
94 const made = await createUser(app.db, {
95 origin: "seed",
96 trusted: true,
97 username: name,
98 email: `${name}@forge.local`,
99 password: "a-good-passphrase",
100 adminIfFirst: true,
101 });
102 if (!made.ok) throw new Error(made.error);
103 if (name === "alice") alice = made.value;
104 cookies[name] = await signIn(name);
105 }
106 aliceToken = createToken(app.db, alice.id, "t", "rw").token;
107 bobToken = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "rw").token;
108 const made = await createRepo(app, alice, { name: "karts", visibility: "public" });
109 if (!made.ok) throw new Error(String(made.error));
110 repo = made.value;
111 const c = await commitToBranch(app, alice, repo, { message: "one", files: [{ path: "a.txt", content: "a\n" }] });
112 if (!c.ok) throw new Error(JSON.stringify(c.error));
113 oid = c.value.commitOid;
114 const c2 = await commitToBranch(app, alice, repo, { message: "two", files: [{ path: "a.txt", content: "b\n" }] });
115 if (!c2.ok) throw new Error(JSON.stringify(c2.error));
116 const pinned = await pinBuild(app, alice, repo, { label: "v1", oid });
117 if (!pinned.ok) throw new Error(pinned.error);
118 const record = insertApp(app.db, { repoId: repo.id, slug: "karts", access: "public" });
119 if (!record.ok) throw new Error(record.error);
120 desktop = look(record.value.id, oid, "desktop", PNG(1));
121 phone = look(record.value.id, oid, "phone", PNG(2));
122 elsewhere = look(record.value.id, c2.value.commitOid, "desktop", PNG(3));
123
124 const hidden = await createRepo(app, alice, { name: "secret", visibility: "private" });
125 if (!hidden.ok) throw new Error(String(hidden.error));
126 secret = hidden.value;
127 const s = await commitToBranch(app, alice, secret, { message: "one", files: [{ path: "a.txt", content: "a\n" }] });
128 if (!s.ok) throw new Error(JSON.stringify(s.error));
129 const sp = await pinBuild(app, alice, secret, { label: "v1", oid: s.value.commitOid });
130 if (!sp.ok) throw new Error(sp.error);
131 const up = await uploadShot(app, alice, secret, "v1", PNG(9), "hidden");
132 if (!up.ok) throw new Error(up.error);
133 });
134
135 afterAll(async () => {
136 servers.stop();
137 app.close();
138 await rm(dataDir, { recursive: true, force: true });
139 });
140
141 test("a writer uploads screenshots; git holds them as a tree with shots.json", async () => {
142 expect((await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(PNG(5), "Start screen"))).status).toBe(201);
143 expect((await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(JPEG, " Race\nend ", "x.bin"))).status).toBe(201);
144 const read = await shots();
145 expect(read.items.map((s) => [s.file, s.caption])).toEqual([["01.png", "Start screen"], ["02.jpg", "Race end"]]);
146 // Git is truth: the ref points at a tree of the pictures and the list.
147 expect(git("cat-file", "-t", "refs/shots/v1")).toBe("tree");
148 expect(git("ls-tree", "--name-only", "refs/shots/v1").split("\n")).toEqual(["01.png", "02.jpg", "shots.json"]);
149 expect(JSON.parse(git("cat-file", "blob", "refs/shots/v1:shots.json"))).toEqual([
150 { file: "01.png", caption: "Start screen" },
151 { file: "02.jpg", caption: "Race end" },
152 ]);
153 expect(git("rev-parse", "refs/shots/v1")).toBe(read.tree!);
154 });
155
156 test("a reader sees each picture with its own type and nosniff", async () => {
157 const png = await get("/alice/karts/builds/v1/shots/01.png", "bob");
158 expect(png.status).toBe(200);
159 expect(png.headers.get("content-type")).toBe("image/png");
160 expect(png.headers.get("x-content-type-options")).toBe("nosniff");
161 expect(new Uint8Array(await png.arrayBuffer())).toEqual(PNG(5));
162 const jpg = await get("/alice/karts/builds/v1/shots/02.jpg");
163 expect(jpg.headers.get("content-type")).toBe("image/jpeg");
164 expect((await get("/alice/karts/builds/v1/shots/03.png")).status).toBe(404);
165 expect((await get("/alice/karts/builds/v1/shots/..%2Fa.txt")).status).toBe(404);
166 });
167
168 test("a picture is one by its first bytes, within 2 MB and 12 a pin", async () => {
169 const before = (await shots()).tree;
170 // A page named .png is refused; so is a picture past the limit.
171 const big = new Uint8Array(SHOT_MAX + 1);
172 big.set(PNG(0));
173 const html = await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(PAGE, "evil", "evil.png"));
174 expect(html.status).toBe(422);
175 expect(((await html.json()) as { error: string }).error).toBe("invalid_shot");
176 const large = await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(big, "big"));
177 expect(large.status).toBe(413);
178 const long = await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(PNG(1), "x".repeat(141)));
179 expect(((await long.json()) as { error: string }).error).toBe("invalid_caption");
180 expect((await shots()).tree).toBe(before);
181 // Fill to twelve; the thirteenth is refused.
182 for (let i = (await shots()).items.length; i < 12; i++) {
183 const r = await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(i % 2 ? WEBP : PNG(i), `n${i}`));
184 expect(r.status).toBe(201);
185 }
186 const full = await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(PNG(1), "one more"));
187 expect(full.status).toBe(409);
188 expect(((await full.json()) as { error: string }).error).toBe("too_many");
189 expect((await shots()).items.length).toBe(12);
190 expect((await shots()).items.some((s) => s.type === "image/webp")).toBe(true);
191 // Back to the two this file started with.
192 for (let left = (await shots()).items; left.length > 2; left = (await shots()).items)
193 expect((await api("DELETE", `/alice/karts/builds/v1/shots/${left[2]!.file}`, aliceToken)).status).toBe(204);
194 });
195
196 test("a writer reorders, captions and removes; each change is a new tree", async () => {
197 const first = (await shots()).tree!;
198 const json = "application/json";
199 const up = await api("POST", "/alice/karts/builds/v1/shots/02.jpg", aliceToken, JSON.stringify({ move: "up", at: first }), json);
200 expect(up.status).toBe(200);
201 expect((await shots()).items.map((s) => [s.file, s.caption])).toEqual([["01.jpg", "Race end"], ["02.png", "Start screen"]]);
202 // A change drawn from the old tree changes nothing.
203 expect((await api("DELETE", `/alice/karts/builds/v1/shots/01.jpg?at=${first}`, aliceToken)).status).toBe(409);
204 expect((await shots()).items.length).toBe(2);
205 const second = (await shots()).tree!;
206 expect(second).not.toBe(first);
207 const titled = await api("POST", "/alice/karts/builds/v1/shots/02.png", aliceToken, JSON.stringify({ caption: "Title", at: second }), json);
208 expect(titled.status).toBe(200);
209 expect((await shots()).items[1]!.caption).toBe("Title");
210 const moved = await api("POST", "/alice/karts/builds/v1/shots/02.png", aliceToken, JSON.stringify({ move: "up", caption: "Title card" }), json);
211 expect(moved.status).toBe(200);
212 const listed = (await moved.json()) as { items: { file: string; caption: string; url: string }[]; next_cursor: null; tree: string };
213 expect(listed.items.map((s) => [s.file, s.caption])).toEqual([["01.png", "Title card"], ["02.jpg", "Race end"]]);
214 expect(listed.next_cursor).toBeNull();
215 expect(listed.items[0]!.url).toMatch(/^\/alice\/karts\/builds\/v1\/shots\/01\.png\?v=[0-9a-f]{12}$/);
216 expect((await api("DELETE", `/alice/karts/builds/v1/shots/02.jpg?at=${listed.tree}`, aliceToken)).status).toBe(204);
217 expect((await shots()).items.map((s) => s.file)).toEqual(["01.png"]);
218 expect(git("ls-tree", "--name-only", "refs/shots/v1").split("\n")).toEqual(["01.png", "shots.json"]);
219 });
220
221 test("a writer picks screenshots from the pictures of the pin's commit", async () => {
222 const offered = await api("GET", "/alice/karts/builds/v1/shots/looks", aliceToken);
223 const ids = ((await offered.json()) as { items: { id: string }[] }).items.map((l) => l.id).sort();
224 expect(ids).toEqual([desktop.id, phone.id].sort());
225 // Another commit's picture is not this pin's to take.
226 const picked = await api("POST", "/alice/karts/builds/v1/shots/looks", aliceToken, JSON.stringify({ looks: [phone.id, desktop.id, elsewhere.id] }), "application/json");
227 expect(picked.status).toBe(201);
228 const read = await shots();
229 expect(read.items.map((s) => s.file)).toEqual(["01.png", "02.png", "03.png"]);
230 expect(new Uint8Array(await (await get("/alice/karts/builds/v1/shots/02.png")).arrayBuffer())).toEqual(PNG(2));
231 expect(new Uint8Array(await (await get("/alice/karts/builds/v1/shots/03.png")).arrayBuffer())).toEqual(PNG(1));
232 const none = await api("POST", "/alice/karts/builds/v1/shots/looks", aliceToken, JSON.stringify({ looks: [elsewhere.id] }), "application/json");
233 expect(((await none.json()) as { error: string }).error).toBe("nothing_picked");
234 });
235
236 test("a reader and a signed-out visitor change nothing", async () => {
237 const before = (await shots()).tree;
238 expect((await api("POST", "/alice/karts/builds/v1/shots", bobToken, upload(PNG(1), "x"))).status).toBe(403);
239 const signedOut = await fetch(`${base}/api/v1/repos/alice/karts/builds/v1/shots/01.png`, { method: "DELETE" });
240 expect(signedOut.status).toBe(401);
241 expect((await api("DELETE", "/alice/karts/builds/v1/shots/01.png", bobToken)).status).toBe(403);
242 expect((await api("POST", "/alice/karts/builds/v1/shots/looks", bobToken, JSON.stringify({ looks: [desktop.id] }), "application/json")).status).toBe(403);
243 expect((await shots()).tree).toBe(before);
244 });
245
246 test("a stranger to a private repository gets 404 for its pictures and its writes", async () => {
247 expect((await get("/alice/secret/builds/v1/shots/01.png", "alice")).status).toBe(200);
248 expect((await get("/alice/secret/builds/v1/shots/01.png", "bob")).status).toBe(404);
249 expect((await get("/alice/secret/builds/v1/shots/01.png")).status).toBe(404);
250 expect((await api("GET", "/alice/secret/builds/v1/shots", bobToken)).status).toBe(404);
251 expect((await api("POST", "/alice/secret/builds/v1/shots", bobToken, upload(PNG(1), "x"))).status).toBe(404);
252 expect((await api("DELETE", "/alice/secret/builds/v1/shots/01.png", bobToken)).status).toBe(404);
253 expect((await readShots(repoDir(app.reposRoot, "alice", "secret"), "v1")).items.length).toBe(1);
254 });
255
256 test("an agent on the crew is refused, as on the demo", async () => {
257 const made = await createAgent(app, alice, { username: "atlas" });
258 if (!made.ok) throw new Error(String(made.error));
259 expect(addToCrew(app, alice, repo, "atlas").ok).toBe(true);
260 const atlas = getUserByName(app.db, "atlas")!;
261 const before = (await shots()).tree;
262 expect(await uploadShot(app, atlas, repo, "v1", PNG(1), "x")).toEqual({ ok: false, error: "forbidden" });
263 expect(await pickShots(app, atlas, repo, "v1", [desktop.id])).toEqual({ ok: false, error: "forbidden" });
264 const token = createToken(app.db, atlas.id, "t", "rw").token;
265 expect((await api("POST", "/alice/karts/builds/v1/shots", token, upload(PNG(1), "x"))).ok).toBe(false);
266 expect((await shots()).tree).toBe(before);
267 });
268
269 test("every change is an event its readers see", () => {
270 const kinds = app.db
271 .query<{ payload: string }, [string]>("SELECT payload FROM event WHERE kind = 'build.shots' AND repo_id = ?1")
272 .all(repo.id)
273 .map((r) => (JSON.parse(r.payload) as { change: string }).change);
274 for (const change of ["added", "moved", "captioned", "removed", "picked"]) expect(kinds).toContain(change);
275 });
276
277 test("two writers racing on one list: one lands, the other is told it moved; a stale `at` is refused", async () => {
278 const before = await shots();
279 const [a, b] = await Promise.all([
280 uploadShot(app, alice, repo, "v1", PNG(7), "a"),
281 uploadShot(app, alice, repo, "v1", PNG(8), "b"),
282 ]);
283 expect([a.ok, b.ok].filter(Boolean).length).toBe(1);
284 expect([a, b].find((r) => !r.ok)).toEqual({ ok: false, error: "moved" });
285 expect((await shots()).items.length).toBe(before.items.length + 1);
286 expect(await uploadShot(app, alice, repo, "v1", PNG(9), "c", before.tree ?? "")).toEqual({ ok: false, error: "moved" });
287 expect((await pickShots(app, alice, repo, "v1", [desktop.id], before.tree ?? "")).ok).toBe(false);
288 });
289
290 test("deleting the pin deletes its screenshots, so a label pinned again starts bare", async () => {
291 expect((await shots()).items.length).toBeGreaterThan(0);
292 const gone = await deletePin(app, alice, repo, "v1");
293 expect(gone.ok).toBe(true);
294 expect((await shots()).items).toEqual([]);
295 expect((await pinBuild(app, alice, repo, { label: "v1", oid })).ok).toBe(true);
296 expect((await shots()).items).toEqual([]);
297 });
298 });
test/security-floor-routes.test.ts +4
@@ -49,11 +49,13 @@ const TALK = "needs a conversation of the signed-in person; the seed starts none
4949 const HOOK = "needs a webhook, which the seed does not register";
5050
5151 /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */
52 const SHOT = "needs a pin with a picture kept by refs/shots; the seed pins nothing";
5253 const UNSEEDED: Record<string, string> = {
5354 "/:owner/-/teams/:team": ORG,
5455 "/talk/:id": TALK,
5556 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
5657 "/:owner/:name/bets/:id": BET,
58 "/:owner/:name/builds/:label/shots/:file": SHOT,
5759 "/:owner/:name/looks/:env/:commit/:kind.png": LOOK,
5860 "/:owner/:name/milestones/:number/limb/:op": LIMB,
5961 "/:owner/:name/variants/:group": VARIANT,
@@ -66,6 +68,8 @@ const UNSEEDED: Record<string, string> = {
6668 "/api/v1/orgs/:org": ORG,
6769 "/api/v1/orgs/:org/teams": ORG,
6870 "/api/v1/orgs/:org/teams/:team/members": ORG,
71 "/api/v1/repos/:owner/:name/builds/:label/shots": SHOT,
72 "/api/v1/repos/:owner/:name/builds/:label/shots/looks": SHOT,
6973 "/api/v1/repos/:owner/:name/gates/:id/log": GATE,
7074 "/api/v1/repos/:owner/:name/hooks/:id/deliveries": HOOK,
7175 "/api/v1/repos/:owner/:name/waves/:version": WAVE,
test/tui-builds.test.ts +24 −1
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { pinBuild } from "../src/actions/builds.ts";
6 import { deletePin, pinBuild } from "../src/actions/builds.ts";
77 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
88 import { createApp, type App } from "../src/app.ts";
99 import type { Repo } from "../src/core/repos.ts";
@@ -121,3 +121,26 @@ test("the command is offered to a reader and not for a repository the viewer can
121121 expect(buildsCommands(stranger, ctx, secret)).toEqual([]);
122122 expect(buildsCommands(stranger, ctx, undefined)).toEqual([]);
123123 });
124
125 test("s on a pin lists its screenshots; a writer moves, captions and removes, a reader only reads", async () => {
126 const { uploadShot } = await import("../src/actions/build_shots.ts");
127 const { readShots } = await import("../src/git/shots.ts");
128 const PNG = (n: number) => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, n, n]);
129 // Pins pinned in one second have no fixed order: leave only ours.
130 for (const p of await pins(open)) await deletePin(app, alice, open, p.label);
131 const pinned = await pinBuild(app, alice, open, { label: "shotpin", oid });
132 if (!pinned.ok) throw new Error(pinned.error);
133 expect((await uploadShot(app, alice, open, "shotpin", PNG(1), "first")).ok).toBe(true);
134 expect((await uploadShot(app, alice, open, "shotpin", PNG(2), "second")).ok).toBe(true);
135 const dir = repoDir(app.reposRoot, "alice", "game");
136 const names = async () => (await readShots(dir, "shotpin")).items.map((s) => s.caption);
137 const read = await drive(open, undefined, ["s"]);
138 expect(read).toContain("01.png");
139 expect(read).toContain("second");
140 await drive(open, undefined, ["s", "d", "x"]);
141 expect(await names()).toEqual(["first", "second"]);
142 await drive(open, alice, ["s", "d"]);
143 expect(await names()).toEqual(["second", "first"]);
144 await drive(open, alice, ["s", "x", ["right", "enter"]]);
145 expect(await names()).toEqual(["first"]);
146 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs