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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9194-port-jay-moran-s-build-screenshots-build → main 3 commits fork
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 { | ||
| 39 | 39 | export type PinBuildActionError = AccessError | PinBuildError | "mirrored"; |
| 40 | 40 | |
| 41 | 41 | /** 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"> { | |
| 43 | 43 | const writer = requireWriter(app, actor, repo); |
| 44 | 44 | if (!writer.ok) return writer; |
| 45 | 45 | 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 | ||
| 14 | 14 | export { route as deleteApiV1ReposOwnerNameAppEnvVar } from "./deleteApiV1ReposOwnerNameAppEnvVar.ts"; |
| 15 | 15 | export { route as deleteApiV1ReposOwnerNameBranchesBranch } from "./deleteApiV1ReposOwnerNameBranchesBranch.ts"; |
| 16 | 16 | export { route as deleteApiV1ReposOwnerNameBuildsLabel } from "./deleteApiV1ReposOwnerNameBuildsLabel.ts"; |
| 17 | export { route as deleteApiV1ReposOwnerNameBuildsLabelShotsFile } from "./deleteApiV1ReposOwnerNameBuildsLabelShotsFile.ts"; | |
| 17 | 18 | export { route as deleteApiV1ReposOwnerNameCrewUsername } from "./deleteApiV1ReposOwnerNameCrewUsername.ts"; |
| 18 | 19 | export { route as deleteApiV1ReposOwnerNameEgressGrants } from "./deleteApiV1ReposOwnerNameEgressGrants.ts"; |
| 19 | 20 | export { route as deleteApiV1ReposOwnerNameHooksId } from "./deleteApiV1ReposOwnerNameHooksId.ts"; |
| @@ -98,6 +99,8 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam | ||
| 98 | 99 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 99 | 100 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 100 | 101 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; |
| 102 | export { route as getApiV1ReposOwnerNameBuildsLabelShots } from "./getApiV1ReposOwnerNameBuildsLabelShots.ts"; | |
| 103 | export { route as getApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts"; | |
| 101 | 104 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| 102 | 105 | export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts"; |
| 103 | 106 | export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts"; |
| @@ -253,6 +256,9 @@ export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1Repos | ||
| 253 | 256 | export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts"; |
| 254 | 257 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 255 | 258 | 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"; | |
| 256 | 262 | export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts"; |
| 257 | 263 | export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts"; |
| 258 | 264 | 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/core/event_audience.ts +2
| @@ -135,6 +135,8 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 135 | 135 | "build.pinned", |
| 136 | 136 | "build.deleted", |
| 137 | 137 | "build.noted", |
| 138 | // A pin's screenshots are read by its repository's readers (`actions/build_shots.ts`). | |
| 139 | "build.shots", | |
| 138 | 140 | "invite.used", |
| 139 | 141 | "user.updated", |
| 140 | 142 | "user.key_added", |
src/core/looks.ts +22
| @@ -255,6 +255,28 @@ export function looksForApps(db: Database, appIds: readonly string[]): Look[] { | ||
| 255 | 255 | return out; |
| 256 | 256 | } |
| 257 | 257 | |
| 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 | ||
| 258 | 280 | /** |
| 259 | 281 | * Forget an environment's looks — all of them, or all but one commit's |
| 260 | 282 | * (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( | ||
| 398 | 398 | return ok({ pin: withNotes(pin, notes), note, commit: written.value }); |
| 399 | 399 | } |
| 400 | 400 | |
| 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 | ||
| 401 | 410 | export type DeletePinError = "not_found" | "moved" | "git_failed"; |
| 402 | 411 | |
| 403 | 412 | /** |
| @@ -416,6 +425,9 @@ export async function deletePin(dir: string, label: string): Promise<Result<{ re | ||
| 416 | 425 | if (tag === undefined) return err("not_found"); |
| 417 | 426 | const commit = await runGitCapture(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`], { cwd: dir }); |
| 418 | 427 | 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}`); | |
| 419 | 431 | const done = await runGitCapture(["update-ref", "--stdin"], { cwd: dir, stdin: `start\n${lines.join("\n")}\nprepare\ncommit\n` }); |
| 420 | 432 | if (done.code !== 0) return err(/cannot lock|is at|expected/i.test(done.stderr) ? "moved" : "git_failed"); |
| 421 | 433 | 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"; | ||
| 353 | 353 | import { userRoutes } from "./api/user.ts"; |
| 354 | 354 | import { appRoutes } from "./api/app.ts"; |
| 355 | 355 | import { buildRoutes } from "./api/builds.ts"; |
| 356 | import { buildShotRoutes } from "./api/build_shots.ts"; | |
| 356 | 357 | import { iterationRoutes } from "./api/iterations.ts"; |
| 357 | 358 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 358 | 359 | import { reportRoutes } from "./api/reports.ts"; |
| @@ -3633,6 +3634,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3633 | 3634 | |
| 3634 | 3635 | appRoutes(router); |
| 3635 | 3636 | buildRoutes(router); |
| 3637 | buildShotRoutes(router); | |
| 3636 | 3638 | iterationRoutes(router); |
| 3637 | 3639 | |
| 3638 | 3640 | 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 { | ||
| 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 | 189 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; |
| 189 | 190 | import { |
| 190 | 191 | contributeSubmit, |
| @@ -1260,6 +1261,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1260 | 1261 | // Build pins (`git/builds.ts`): the list, the form, and the writes behind them. |
| 1261 | 1262 | router.add("GET", "/:owner/:name/builds", buildsPage); |
| 1262 | 1263 | 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); | |
| 1263 | 1266 | router.add("GET", "/:owner/:name/iterations", iterationsPage); |
| 1264 | 1267 | router.add("POST", "/:owner/:name/builds", pinBuildSubmit); |
| 1265 | 1268 | 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"; | ||
| 20 | 20 | import type { Session } from "../session.ts"; |
| 21 | 21 | import { style } from "../term/style.ts"; |
| 22 | 22 | import { truncate } from "../term/width.ts"; |
| 23 | import { shotsScreen } from "./shots.ts"; | |
| 23 | 24 | import { ListState, ago, confirmScreen, metaOf, promptScreen } from "./common.ts"; |
| 24 | 25 | |
| 25 | 26 | export const NOT_FOUND_LINE = "No such repository"; |
| @@ -113,6 +114,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 113 | 114 | get keys() { |
| 114 | 115 | return [ |
| 115 | 116 | binding(["j", "k"], "move"), |
| 117 | binding(["s"], "screenshots"), | |
| 116 | 118 | ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["d"], "delete")] : []), |
| 117 | 119 | binding(["esc", "q"], "back"), |
| 118 | 120 | ]; |
| @@ -150,6 +152,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 150 | 152 | if (e.key === "j" || e.key === "down") state.move(1, pins?.length ?? 0); |
| 151 | 153 | else if (e.key === "k" || e.key === "up") state.move(-1, pins?.length ?? 0); |
| 152 | 154 | 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)); | |
| 153 | 156 | else if (e.key === "p" && write) void ctx.track(pin()); |
| 154 | 157 | else if (e.key === "n" && write && picked !== undefined) addNote(picked); |
| 155 | 158 | 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 | ||
| 168 | 171 | { |
| 169 | 172 | id: "builds", |
| 170 | 173 | 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", | |
| 172 | 175 | run: () => ctx.push(buildsScreen(f, ctx, repo)), |
| 173 | 176 | }, |
| 174 | 177 | ]; |
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 | ||
| 49 | 49 | const HOOK = "needs a webhook, which the seed does not register"; |
| 50 | 50 | |
| 51 | 51 | /** 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"; | |
| 52 | 53 | const UNSEEDED: Record<string, string> = { |
| 53 | 54 | "/:owner/-/teams/:team": ORG, |
| 54 | 55 | "/talk/:id": TALK, |
| 55 | 56 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 56 | 57 | "/:owner/:name/bets/:id": BET, |
| 58 | "/:owner/:name/builds/:label/shots/:file": SHOT, | |
| 57 | 59 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, |
| 58 | 60 | "/:owner/:name/milestones/:number/limb/:op": LIMB, |
| 59 | 61 | "/:owner/:name/variants/:group": VARIANT, |
| @@ -66,6 +68,8 @@ const UNSEEDED: Record<string, string> = { | ||
| 66 | 68 | "/api/v1/orgs/:org": ORG, |
| 67 | 69 | "/api/v1/orgs/:org/teams": ORG, |
| 68 | 70 | "/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, | |
| 69 | 73 | "/api/v1/repos/:owner/:name/gates/:id/log": GATE, |
| 70 | 74 | "/api/v1/repos/:owner/:name/hooks/:id/deliveries": HOOK, |
| 71 | 75 | "/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"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 6 | import { deletePin, pinBuild } from "../src/actions/builds.ts"; | |
| 7 | 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 8 | 8 | import { createApp, type App } from "../src/app.ts"; |
| 9 | 9 | 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 | ||
| 121 | 121 | expect(buildsCommands(stranger, ctx, secret)).toEqual([]); |
| 122 | 122 | expect(buildsCommands(stranger, ctx, undefined)).toEqual([]); |
| 123 | 123 | }); |
| 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 | }); |
new above
offline