Public: anyone can read this; the crew can change it.
Port Jay Moran's arcade carts: src/arcade #9217
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9197-port-jay-moran-s-arcade-carts-src-arcade → main 10 commits fork 4 threads · 4 unresolved
19 files
changed +1210
−16
docs/features/arcade-carts.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Arcade carts | |
| 2 | ||
| 3 | A build pin can name a **cart**: a demo of its commit that plays offline as one HTML page. The cart's facts ride on the pin's tag as trailers (`Demo:`, `Version:`, `Launch: file`, `Page:`, `Showcase: on`, `Cover:`; `git/builds.ts`), so git holds them. The page is captured once from a build preview of the commit and kept under `FORGE_APP_DATA/carts/<owner>/<repo>/<commit>/` with a picture of it (`arcade/carts.ts`). Ported from Jay Moran's arcade. | |
| 4 | ||
| 5 | - **Who.** Anyone who reads the repository sees a cart and plays it. Naming one 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 | - **Web.** On **Builds**, a pin with a cart shows `▶ name version`; it opens the captured page at `/:owner/:name/builds/:label/cart`, and its picture is at `…/cart.png`. The page is the build's own HTML, served sandboxed (`sandbox allow-scripts`, no same origin): it runs its scripts and reaches nothing of the forge's. | |
| 7 | - **API.** | |
| 8 | - `GET /api/v1/repos/:owner/:name/builds/:label/cart` answers `{ label, oid, demo, capture, play_url, art_url }`; `capture.state` is `none`, `capturing`, `done` or `failed` (with its `error`). | |
| 9 | - `POST …/cart` with `{ name, version, page, showcase }` names it and starts the capture: 202 while it runs. The capture starts the commit's build preview if it is not up, waits for it (15 minutes at most), keeps the page once (16 MB at most) and pictures it. | |
| 10 | - **Terminal.** **Builds** → a pin shows `▶ name version`; a writer presses `c` to name its cart (`name version page`) and start the capture. | |
| 11 | - **Import.** Carts built elsewhere: `bun scripts/import-carts.ts <data-dir> <carts.json>` pins each commit (if not pinned), sets its cart and cover, and keeps its page and picture. Each entry is checked; a bad one is named and skipped. | |
| 12 | ||
| 13 | Left out: the Arcade itself (the shelf, the console page, covers drawn in the house style and the arcade host: #9207, #9208), a cover upload door, and capturing a page that is not the build's own HTML file. |
scripts/hollow-allow.ts +2
| @@ -19,4 +19,6 @@ export type Allowed = { | ||
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | 21 | export const ALLOWED: readonly Allowed[] = [ |
| 22 | { orphan: "src/actions/forge_view.ts#viewBuilds", reason: "a counter only test/api-forge-view.test.ts reads, to prove a repeat reuses the view", issue: 9193 }, | |
Unrelated to the carts (#9193). Remove it; that pull owns the fix. |
||
| 23 | { orphan: "src/actions/foundry_door.ts#forgetFoundryDoor", reason: "a reset only test/api-forge-view.test.ts calls, after it installs or removes the app", issue: 9193 }, | |
| 22 | 24 | ]; |
scripts/import-carts.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * Import carts built outside the forge into a forge's data directory | |
| 3 | * (`src/arcade/import.ts`). | |
| 4 | * | |
| 5 | * bun scripts/import-carts.ts <data-dir> <carts.json> [tagger-name] [tagger-email] | |
| 6 | * | |
| 7 | * carts.json is a list of entries: `repo`, `label`, `sha`, `note`, `cart` | |
| 8 | * (`name`, `version`, `page`, `shelf`; a `producer` is ignored, the | |
| 9 | * Arcade names its own), and the paths `file`, | |
| 10 | * `cover` and `art`, relative to the JSON file. | |
| 11 | */ | |
| 12 | ||
| 13 | import { existsSync, readFileSync } from "node:fs"; | |
| 14 | import { dirname, resolve } from "node:path"; | |
| 15 | import { importCart } from "../src/arcade/import.ts"; | |
| 16 | ||
| 17 | const [dataDir, list, name = "forge", email = "forge@localhost"] = process.argv.slice(2); | |
| 18 | if (dataDir === undefined || list === undefined) { | |
| 19 | console.error("usage: bun scripts/import-carts.ts <data-dir> <carts.json> [tagger-name] [tagger-email]"); | |
| 20 | process.exit(2); | |
| 21 | } | |
| 22 | const here = dirname(resolve(list)); | |
| 23 | const read = (path: unknown): Uint8Array | string => { | |
| 24 | if (typeof path !== "string" || path === "") return "a path is missing"; | |
| 25 | const full = resolve(here, path); | |
| 26 | if (!existsSync(full)) return `${path} is not there`; | |
| 27 | return new Uint8Array(readFileSync(full)); | |
| 28 | }; | |
| 29 | const isText = (v: unknown, max = 200): v is string => typeof v === "string" && v.length > 0 && v.length <= max; | |
| 30 | const parsed: unknown = JSON.parse(readFileSync(list, "utf8")); | |
| 31 | if (!Array.isArray(parsed)) { | |
| 32 | console.error(`${list}: expected a list of carts`); | |
| 33 | process.exit(2); | |
| 34 | } | |
| 35 | let failed = false; | |
| 36 | const refuse = (what: string, why: string) => { | |
| 37 | failed = true; | |
| 38 | console.error(`${what}: ${why}`); | |
outdated
|
||
| 39 | }; | |
| 40 | for (const [n, raw] of parsed.entries()) { | |
| 41 | const e = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>; | |
| 42 | const cart = (typeof e.cart === "object" && e.cart !== null ? e.cart : {}) as Record<string, unknown>; | |
| 43 | const what = isText(e.repo) && isText(e.label) ? `${e.repo} ${e.label}` : `entry ${n}`; | |
| 44 | if (!isText(e.repo) || !isText(e.label) || !isText(e.sha, 64)) { refuse(what, "repo, label and sha are text"); continue; } | |
| 45 | if (!isText(cart.name) || !isText(cart.version) || !isText(cart.page) || typeof cart.shelf !== "boolean") { refuse(what, "cart needs name, version, page and shelf"); continue; } | |
| 46 | if (e.note !== undefined && typeof e.note !== "string") { refuse(what, "note is text"); continue; } | |
| 47 | const html = read(e.file); | |
| 48 | if (typeof html === "string") { refuse(what, `file: ${html}`); continue; } | |
| 49 | const cover = e.cover === undefined ? undefined : read(e.cover); | |
| 50 | const art = e.art === undefined ? undefined : read(e.art); | |
| 51 | if (typeof cover === "string" || typeof art === "string") { refuse(what, `cover or art: ${cover ?? art}`); continue; } | |
| 52 | const done = await importCart( | |
| 53 | resolve(dataDir), | |
| 54 | { repo: e.repo, label: e.label, sha: e.sha, cart: { name: cart.name, version: cart.version, page: cart.page, shelf: cart.shelf }, html, cover, art, ...(e.note === undefined ? {} : { note: e.note }) }, | |
| 55 | { name, email }, | |
| 56 | ); | |
| 57 | if (done.ok) console.log(`${what}: ${done.value.pinned ? "pinned, " : ""}${done.value.captured ? "captured" : "capture kept"}`); | |
| 58 | else refuse(what, done.error); | |
| 59 | } | |
| 60 | process.exit(failed ? 1 : 0); |
src/actions/build_cart.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * A pin's cart: the demo it names (`git/builds.ts`) and the one page of its | |
| 3 | * commit captured to play offline (`arcade/carts.ts`). Reading is the | |
| 4 | * repository's readers'; naming a cart and capturing it is a person who | |
| 5 | * writes it. The capture runs in the background; `cartOf` says how it | |
| 6 | * stands. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { captureState, startCapture, type CaptureOptions, type CaptureState } from "../arcade/carts.ts"; | |
| 11 | import { canRead, type Repo } from "../core/repos.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { isDemo, readBuildPin, setPinDemo, type Demo } from "../git/builds.ts"; | |
| 14 | import { repoDir } from "../git/repos.ts"; | |
| 15 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 16 | import type { AccessError } from "./access.ts"; | |
| 17 | import { writerOf } from "./builds.ts"; | |
| 18 | ||
| 19 | export interface Cart { | |
| 20 | readonly label: string; | |
| 21 | readonly oid: string; | |
| 22 | readonly demo: Demo | undefined; | |
| 23 | readonly capture: CaptureState; | |
| 24 | } | |
| 25 | ||
| 26 | export type CartError = AccessError | "mirrored" | "invalid_demo" | "moved" | "git_failed"; | |
| 27 | ||
| 28 | /** What a person names: the cart's name and version, the page it opens, and whether it is in the showcase. */ | |
| 29 | export interface CartInput { | |
| 30 | readonly name: string; | |
| 31 | readonly version: string; | |
| 32 | readonly page: string; | |
| 33 | readonly showcase: boolean; | |
| 34 | } | |
| 35 | ||
| 36 | /** The pin `label`'s cart, to whoever reads the repository. */ | |
| 37 | export async function cartOf(app: App, actor: User | undefined, repo: Repo, label: string): Promise<Result<Cart, "not_found">> { | |
| 38 | if (!canRead(app.db, repo, actor)) return err("not_found"); | |
| 39 | const pin = await readBuildPin(repoDir(app.reposRoot, repo.ownerName, repo.name), label); | |
| 40 | if (pin === undefined) return err("not_found"); | |
| 41 | const capture = pin.demo === undefined ? ({ state: "none" } as const) : captureState(app, repo, pin.oid, pin.demo.page); | |
| 42 | return ok({ label: pin.label, oid: pin.oid, demo: pin.demo, capture }); | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Name the pin `label`'s cart and start capturing its page. A cart opens as | |
| 47 | * a file: its captured page. The pin's cover, if it has one, stays. | |
| 48 | */ | |
| 49 | export async function setCart( | |
| 50 | app: App, | |
| 51 | actor: User | undefined, | |
| 52 | repo: Repo, | |
| 53 | label: string, | |
| 54 | input: CartInput, | |
| 55 | options: CaptureOptions = {}, | |
| 56 | ): Promise<Result<Cart, CartError>> { | |
| 57 | const writer = writerOf(app, actor, repo); | |
| 58 | if (!writer.ok) return writer; | |
| 59 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 60 | const pin = await readBuildPin(dir, label); | |
| 61 | if (pin === undefined) return err("not_found"); | |
| 62 | const demo: Demo = { | |
| 63 | name: input.name.trim(), | |
| 64 | version: input.version.trim(), | |
| 65 | launch: "file", | |
| 66 | page: input.page.trim() || "/", | |
| 67 | showcase: input.showcase, | |
| 68 | ...(pin.demo?.cover === undefined ? {} : { cover: pin.demo.cover }), | |
| 69 | }; | |
| 70 | if (!isDemo(demo)) return err("invalid_demo"); | |
| 71 | const set = await setPinDemo(dir, label, demo); | |
| 72 | if (!set.ok) return err(set.error); | |
| 73 | if (captureState(app, repo, pin.oid, demo.page).state === "none") void startCapture(app, writer.value, repo, pin.oid, demo.page, options); | |
| 74 | return ok({ label, oid: pin.oid, demo, capture: captureState(app, repo, pin.oid, demo.page) }); | |
| 75 | } |
src/arcade/carts.ts added +283
| @@ -0,0 +1,283 @@ | ||
| 1 | /** | |
| 2 | * Cart captures: the one page a pin releases, saved once from its running | |
| 3 | * build. A capture is the cart itself — what the Arcade plays and what | |
| 4 | * Download hands over — so it never has to wait for a preview again. | |
| 5 | * | |
| 6 | * Captures live in the data directory, at | |
| 7 | * `carts/<owner>/<repo>/<sha>/<page>.html`, with a picture of the page | |
| 8 | * beside it as `<page>.png` for the cover. A file there is never written | |
| 9 | * twice: the commit is fixed, so its page is too. | |
| 10 | * | |
| 11 | * Capturing is the slow part: the build's preview is started if it is not | |
| 12 | * running, waited for, and asked for the page. It runs in the background; | |
| 13 | * what a panel shows meanwhile is held in memory, and a forge coming back | |
| 14 | * simply shows the capture as missing, with Capture again beside it. | |
| 15 | */ | |
| 16 | ||
| 17 | import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 18 | import { dirname, join } from "node:path"; | |
| 19 | import type { App } from "../app.ts"; | |
| 20 | import { previewBuild, type PreviewBuildError } from "../actions/build_preview.ts"; | |
| 21 | import { buildEnv } from "../apps/data.ts"; | |
| 22 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 23 | import { getAppByRepo } from "../core/apps.ts"; | |
| 24 | import type { Repo } from "../core/repos.ts"; | |
| 25 | import type { User } from "../core/users.ts"; | |
| 26 | import { isDemoPage } from "../git/builds.ts"; | |
| 27 | import { isSafeOid } from "../git/guard.ts"; | |
| 28 | import { takeLook, type Capture } from "../looks/capture.ts"; | |
| 29 | import { createLog } from "../kernel/log.ts"; | |
| 30 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 31 | ||
| 32 | const log = createLog("carts"); | |
| 33 | ||
| 34 | export const cartsRoot = (dataDir: string): string => join(dataDir, "carts"); | |
| 35 | ||
| 36 | /** A page's file name: `/` is `index`, `/spicy` is `spicy`. */ | |
| 37 | export const pageFile = (page: string): string => | |
| 38 | page === "/" ? "index" : page.slice(1).replaceAll("/", "-"); | |
| 39 | ||
| 40 | /** Where a commit's page is kept, without its extension. */ | |
| 41 | export function capturePath(dataDir: string, repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): string { | |
| 42 | return join(cartsRoot(dataDir), repo.ownerName, repo.name, oid, pageFile(page)); | |
| 43 | } | |
| 44 | ||
| 45 | const valid = (oid: string, page: string): boolean => isSafeOid(oid) && /^[0-9a-f]{40,64}$/.test(oid) && isDemoPage(page); | |
| 46 | ||
| 47 | /** The captured page, or undefined. */ | |
| 48 | export function readCapture(dataDir: string, repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): Uint8Array | undefined { | |
| 49 | if (!valid(oid, page)) return undefined; | |
| 50 | const path = `${capturePath(dataDir, repo, oid, page)}.html`; | |
| 51 | return existsSync(path) ? new Uint8Array(readFileSync(path)) : undefined; | |
| 52 | } | |
| 53 | ||
| 54 | /** The picture of the captured page, or undefined. */ | |
| 55 | export function readArt(dataDir: string, repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): Uint8Array | undefined { | |
| 56 | if (!valid(oid, page)) return undefined; | |
| 57 | const path = `${capturePath(dataDir, repo, oid, page)}.png`; | |
| 58 | return existsSync(path) ? new Uint8Array(readFileSync(path)) : undefined; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Write `bytes` at `path` only if nothing is there: written to one side, | |
| 63 | * then linked in, which fails rather than replace a file. False when a | |
| 64 | * file was already there. | |
| 65 | */ | |
| 66 | export function writeOnce(path: string, bytes: Uint8Array): boolean { | |
| 67 | if (existsSync(path)) return false; | |
| 68 | mkdirSync(dirname(path), { recursive: true }); | |
| 69 | const staged = `${path}.${process.pid}.${Date.now()}.part`; | |
| 70 | writeFileSync(staged, bytes); | |
| 71 | try { | |
| 72 | linkSync(staged, path); | |
| 73 | return true; | |
| 74 | } catch { | |
| 75 | return false; | |
| 76 | } finally { | |
| 77 | rmSync(staged, { force: true }); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /** Store a capture made elsewhere — the forge CLI's import. False if one is there. */ | |
| 82 | export function importCapture( | |
| 83 | dataDir: string, | |
| 84 | repo: Pick<Repo, "ownerName" | "name">, | |
| 85 | oid: string, | |
| 86 | page: string, | |
| 87 | html: Uint8Array, | |
| 88 | art?: Uint8Array, | |
| 89 | ): Result<boolean, "invalid"> { | |
| 90 | if (!valid(oid, page)) return err("invalid"); | |
| 91 | const base = capturePath(dataDir, repo, oid, page); | |
| 92 | const wrote = writeOnce(`${base}.html`, html); | |
| 93 | if (art !== undefined) writeOnce(`${base}.png`, art); | |
| 94 | return ok(wrote); | |
| 95 | } | |
| 96 | ||
| 97 | export type CaptureError = | |
| 98 | | PreviewBuildError | |
| 99 | | "not_an_app" | |
| 100 | | "invalid" | |
| 101 | | "build_failed" | |
| 102 | | "timed_out" | |
| 103 | | "page_missing"; | |
| 104 | ||
| 105 | export type CaptureState = | |
| 106 | | { readonly state: "done" } | |
| 107 | | { readonly state: "capturing" } | |
| 108 | | { readonly state: "failed"; readonly error: CaptureError; readonly detail?: string } | |
| 109 | | { readonly state: "none" }; | |
| 110 | ||
| 111 | /** What a capture under way, or one that failed, looks like this boot. */ | |
| 112 | const jobs = new WeakMap<App, Map<string, CaptureState>>(); | |
| 113 | const jobKey = (repo: Repo, oid: string, page: string): string => `${repo.id}:${oid}:${page}`; | |
| 114 | const jobsOf = (app: App): Map<string, CaptureState> => { | |
| 115 | let map = jobs.get(app); | |
| 116 | if (map === undefined) jobs.set(app, (map = new Map())); | |
| 117 | return map; | |
| 118 | }; | |
| 119 | ||
| 120 | /** Whether a page is captured, being captured, or failed. */ | |
| 121 | export function captureState(app: App, repo: Repo, oid: string, page: string): CaptureState { | |
| 122 | if (!valid(oid, page)) return { state: "none" }; | |
| 123 | if (existsSync(`${capturePath(app.config.dataDir, repo, oid, page)}.html`)) return { state: "done" }; | |
| 124 | return jobsOf(app).get(jobKey(repo, oid, page)) ?? { state: "none" }; | |
| 125 | } | |
| 126 | ||
| 127 | export interface CaptureOptions { | |
| 128 | /** How long the build may take to come up. */ | |
| 129 | readonly waitMs?: number; | |
| 130 | /** What takes the picture; a test passes a stand-in. */ | |
| 131 | readonly picture?: Capture; | |
| 132 | /** The commit's build preview; by default the app's own (`previewOf`). */ | |
| 133 | readonly build?: Build; | |
| 134 | /** How often the build is looked at while it comes up. */ | |
| 135 | readonly pollMs?: number; | |
| 136 | } | |
| 137 | ||
| 138 | /** A commit's build preview, as a capture sees it: how it stands, and how to start it. */ | |
| 139 | export interface Build { | |
| 140 | readonly up: () => { readonly state: string; readonly port?: number | undefined } | undefined; | |
| 141 | readonly start: () => Promise<Result<unknown, CaptureError>>; | |
| 142 | } | |
| 143 | ||
| 144 | /** The build preview of `oid` in `repo`'s app, or undefined when it is no app. */ | |
| 145 | function previewOf(app: App, actor: User | undefined, repo: Repo, oid: string): Build | undefined { | |
| 146 | const record = getAppByRepo(app.db, repo.id); | |
| 147 | const runtime = appsRuntime(app); | |
| 148 | if (record === undefined || runtime === undefined) return undefined; | |
| 149 | const env = buildEnv(oid); | |
| 150 | return { | |
| 151 | up: () => runtime.status(record).find((e) => e.env === env && e.commitOid === oid), | |
| 152 | start: () => previewBuild(app, actor, repo, oid), | |
| 153 | }; | |
| 154 | } | |
| 155 | ||
| 156 | /** How long a build gets to come up before the capture gives up. */ | |
| 157 | const WAIT_MS = 15 * 60_000; | |
| 158 | /** The picture's window: a game's wide screen, which the cover crops to its middle square. */ | |
| 159 | const ART_WINDOW = { width: 960, height: 624 } as const; | |
| 160 | ||
| 161 | /** | |
| 162 | * Capture one page of `oid`: start its build preview as `actor` if it is | |
| 163 | * not running, wait for it, save the page once and picture it. Resolves | |
| 164 | * when done; `startCapture` is the same, in the background. | |
| 165 | */ | |
| 166 | export async function captureCart( | |
| 167 | app: App, | |
| 168 | actor: User | undefined, | |
| 169 | repo: Repo, | |
| 170 | oid: string, | |
| 171 | page: string, | |
| 172 | options: CaptureOptions = {}, | |
| 173 | ): Promise<Result<void, CaptureError>> { | |
| 174 | if (!valid(oid, page)) return err("invalid"); | |
| 175 | const base = capturePath(app.config.dataDir, repo, oid, page); | |
| 176 | if (existsSync(`${base}.html`)) return ok(undefined); | |
| 177 | const build = options.build ?? previewOf(app, actor, repo, oid); | |
| 178 | if (build === undefined) return err("not_an_app"); | |
| 179 | if (build.up()?.state !== "running") { | |
| 180 | const started = await build.start(); | |
| 181 | if (!started.ok) return started; | |
| 182 | } | |
| 183 | const deadline = Date.now() + (options.waitMs ?? WAIT_MS); | |
| 184 | let port: number | undefined; | |
| 185 | for (;;) { | |
| 186 | const now = build.up(); | |
| 187 | if (now?.state === "running" && now.port !== undefined) { | |
| 188 | port = now.port; | |
| 189 | break; | |
| 190 | } | |
| 191 | if (now?.state === "failed") return err("build_failed"); | |
| 192 | if (Date.now() > deadline) return err("timed_out"); | |
| 193 | await Bun.sleep(options.pollMs ?? 500); | |
| 194 | } | |
| 195 | return keepPage(base, `http://127.0.0.1:${port}${page}`, options.picture ?? takeLook, `${repo.ownerName}/${repo.name}`, oid, page); | |
| 196 | } | |
| 197 | ||
| 198 | /** The most bytes of a build's page a capture keeps. */ | |
| 199 | export const PAGE_MAX = 16 * 1024 * 1024; | |
| 200 | ||
| 201 | /** A response body read up to `max` bytes; undefined if it is longer. */ | |
| 202 | async function readCapped(answer: Response, max: number): Promise<Uint8Array | undefined> { | |
| 203 | const declared = Number(answer.headers.get("content-length") ?? 0); | |
| 204 | if (declared > max || answer.body === null) return declared > max ? undefined : new Uint8Array(); | |
| 205 | const chunks: Uint8Array[] = []; | |
| 206 | let total = 0; | |
| 207 | for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) { | |
| 208 | total += chunk.length; | |
| 209 | if (total > max) { | |
| 210 | await answer.body.cancel().catch(() => {}); | |
| 211 | return undefined; | |
| 212 | } | |
| 213 | chunks.push(chunk); | |
| 214 | } | |
| 215 | return Buffer.concat(chunks); | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * Fetch `url` once, keep it at `base.html` only if nothing is there, then | |
| 220 | * picture it for the cover's art. A page over `max` bytes is not kept. | |
| 221 | */ | |
| 222 | export async function keepPage( | |
| 223 | base: string, | |
| 224 | url: string, | |
| 225 | picture: Capture, | |
| 226 | who = "", | |
| 227 | oid = "", | |
| 228 | page = "", | |
| 229 | max = PAGE_MAX, | |
| 230 | ): Promise<Result<void, "page_missing">> { | |
| 231 | let bytes: Uint8Array | undefined; | |
| 232 | try { | |
| 233 | const answer = await fetch(url, { redirect: "manual" }); | |
| 234 | if (!answer.ok) return err("page_missing"); | |
| 235 | bytes = await readCapped(answer, max); | |
| 236 | } catch { | |
| 237 | return err("page_missing"); | |
| 238 | } | |
| 239 | if (bytes === undefined) return err("page_missing"); | |
| 240 | writeOnce(`${base}.html`, bytes); | |
| 241 | // The picture is the cover's art; a cart without one still plays. | |
| 242 | const staged = `${base}.${process.pid}.shot.png`; | |
| 243 | const shot = await picture({ url, out: staged, ...ART_WINDOW, timeoutMs: 30_000 }); | |
| 244 | if (shot.ok && existsSync(staged)) writeOnce(`${base}.png`, new Uint8Array(readFileSync(staged))); | |
| 245 | else if (!shot.ok) log.warn("cart picture not taken", { repo: who, oid, page, error: shot.error }); | |
| 246 | rmSync(staged, { force: true }); | |
| 247 | return ok(undefined); | |
| 248 | } | |
| 249 | ||
| 250 | /** A failed build's first error line, from its environment, so a person reads why. */ | |
| 251 | function failedDetail(app: App, repo: Repo, oid: string, error: CaptureError): { detail?: string } { | |
| 252 | if (error !== "build_failed") return {}; | |
| 253 | const record = getAppByRepo(app.db, repo.id); | |
| 254 | const env = record === undefined ? undefined : appsRuntime(app)?.status(record).find((e) => e.env === buildEnv(oid)); | |
| 255 | const first = env?.error?.split("\n")[0]?.trim(); | |
| 256 | return first === undefined || first === "" ? {} : { detail: first }; | |
| 257 | } | |
| 258 | ||
| 259 | /** Capture in the background; the panel reads `captureState` meanwhile. */ | |
| 260 | export function startCapture( | |
| 261 | app: App, | |
| 262 | actor: User | undefined, | |
| 263 | repo: Repo, | |
| 264 | oid: string, | |
| 265 | page: string, | |
| 266 | options: CaptureOptions = {}, | |
| 267 | ): Promise<Result<void, CaptureError>> { | |
| 268 | const map = jobsOf(app); | |
| 269 | const key = jobKey(repo, oid, page); | |
| 270 | map.set(key, { state: "capturing" }); | |
| 271 | return captureCart(app, actor, repo, oid, page, options).then( | |
| 272 | (done) => { | |
| 273 | if (done.ok) map.delete(key); | |
| 274 | else map.set(key, { state: "failed", error: done.error, ...failedDetail(app, repo, oid, done.error) }); | |
| 275 | return done; | |
| 276 | }, | |
| 277 | (error: unknown) => { | |
| 278 | log.error("cart capture failed", { oid, page, error: String(error) }); | |
| 279 | map.set(key, { state: "failed", error: "page_missing" }); | |
| 280 | return err("page_missing" as const); | |
| 281 | }, | |
| 282 | ); | |
| 283 | } |
src/arcade/import.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * Bringing carts in that were built outside the forge: a game whose | |
| 3 | * offline file comes from its own build step, not from a page its app | |
| 4 | * serves (Krazy Karts' `build:file`), or a shelf that lived elsewhere | |
| 5 | * before the Arcade. An operator runs it on the forge's own data | |
| 6 | * directory (`scripts/import-carts.ts`); each entry pins its commit if no | |
| 7 | * pin has that label yet, writes the cart facts and cover onto the pin, | |
| 8 | * and stores the file as that commit's capture of the page. | |
| 9 | * | |
| 10 | * A capture is still written once: a page already captured is kept. | |
| 11 | */ | |
| 12 | ||
| 13 | import { join } from "node:path"; | |
| 14 | import { pinBuild, readBuildPin, setPinDemo, storeCover, type Demo } from "../git/builds.ts"; | |
| 15 | import { repoDir } from "../git/repos.ts"; | |
| 16 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 17 | import { importCapture } from "./carts.ts"; | |
| 18 | ||
| 19 | export interface CartImport { | |
| 20 | /** `owner/name`. */ | |
| 21 | readonly repo: string; | |
| 22 | readonly label: string; | |
| 23 | /** The commit, full id, the cart was built from. */ | |
| 24 | readonly sha: string; | |
| 25 | readonly note?: string; | |
| 26 | /** The cart's facts; `shelf` puts it in the showcase. A `producer` is the Arcade's own, not the pin's. */ | |
| 27 | readonly cart: Pick<Demo, "name" | "version" | "page"> & { readonly shelf: boolean }; | |
| 28 | /** The cart file's bytes. */ | |
| 29 | readonly html: Uint8Array; | |
| 30 | /** An uploaded cover, already titled. */ | |
| 31 | readonly cover?: Uint8Array | undefined; | |
| 32 | /** A picture of the page, for a cover drawn in the house style. */ | |
| 33 | readonly art?: Uint8Array | undefined; | |
| 34 | } | |
| 35 | ||
| 36 | export interface ImportResult { | |
| 37 | readonly label: string; | |
| 38 | readonly pinned: boolean; | |
| 39 | readonly captured: boolean; | |
| 40 | } | |
| 41 | ||
| 42 | /** Import one cart into the forge at `dataDir`, as `tagger`. */ | |
| 43 | export async function importCart( | |
| 44 | dataDir: string, | |
| 45 | entry: CartImport, | |
| 46 | tagger: { readonly name: string; readonly email: string }, | |
| 47 | ): Promise<Result<ImportResult, string>> { | |
| 48 | const [owner = "", name = ""] = entry.repo.split("/"); | |
| 49 | if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(owner) || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) | |
| 50 | return err(`${entry.repo}: not owner/name`); | |
| 51 | const dir = repoDir(join(dataDir, "repos"), owner, name); | |
| 52 | let pin = await readBuildPin(dir, entry.label); | |
| 53 | let pinned = false; | |
| 54 | if (pin === undefined) { | |
| 55 | const made = await pinBuild(dir, { | |
| 56 | label: entry.label, | |
| 57 | oid: entry.sha, | |
| 58 | note: entry.note ?? "", | |
| 59 | source: "import", | |
| 60 | tagger, | |
| 61 | }); | |
| 62 | if (!made.ok) return err(`${entry.repo} ${entry.label}: ${made.error}`); | |
| 63 | pin = made.value; | |
| 64 | pinned = true; | |
| 65 | } else if (pin.oid !== entry.sha) return err(`${entry.repo} ${entry.label}: pinned at ${pin.oid}, not ${entry.sha}`); | |
| 66 | let cover = pin.demo?.cover; | |
| 67 | if (entry.cover !== undefined) { | |
| 68 | const stored = await storeCover(dir, entry.label, entry.cover); | |
| 69 | if (!stored.ok) return err(`${entry.repo} ${entry.label}: cover ${stored.error}`); | |
| 70 | cover = stored.value; | |
| 71 | } | |
| 72 | // A cart is a captured page: it opens as a file. | |
| 73 | const demo: Demo = { | |
| 74 | name: entry.cart.name, | |
| 75 | version: entry.cart.version, | |
| 76 | launch: "file", | |
| 77 | page: entry.cart.page, | |
| 78 | showcase: entry.cart.shelf, | |
| 79 | ...(cover === undefined ? {} : { cover }), | |
| 80 | }; | |
| 81 | const set = await setPinDemo(dir, entry.label, demo); | |
| 82 | if (!set.ok) return err(`${entry.repo} ${entry.label}: ${set.error}`); | |
| 83 | const captured = importCapture(dataDir, { ownerName: owner, name }, entry.sha, entry.cart.page, entry.html, entry.art); | |
| 84 | if (!captured.ok) return err(`${entry.repo} ${entry.label}: page ${entry.cart.page} is no capture path`); | |
| 85 | return ok({ label: entry.label, pinned, captured: captured.value }); | |
| 86 | } |
src/contract/openapi/routes/getApiV1ReposOwnerNameBuildsLabelCart.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}/cart`, | |
| 6 | summary: "A pin's cart: { label, oid, demo, capture, play_url, art_url }; capture.state is none, capturing, done or failed, and play_url is set once the page is captured", | |
| 7 | auth: false, | |
| 8 | }; |
src/contract/openapi/routes/index.ts +2
| @@ -100,6 +100,7 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam | ||
| 100 | 100 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 101 | 101 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 102 | 102 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; |
| 103 | export { route as getApiV1ReposOwnerNameBuildsLabelCart } from "./getApiV1ReposOwnerNameBuildsLabelCart.ts"; | |
| 103 | 104 | export { route as getApiV1ReposOwnerNameBuildsLabelShots } from "./getApiV1ReposOwnerNameBuildsLabelShots.ts"; |
| 104 | 105 | export { route as getApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts"; |
| 105 | 106 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| @@ -257,6 +258,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1Repos | ||
| 257 | 258 | export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts"; |
| 258 | 259 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 259 | 260 | export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts"; |
| 261 | export { route as postApiV1ReposOwnerNameBuildsLabelCart } from "./postApiV1ReposOwnerNameBuildsLabelCart.ts"; | |
| 260 | 262 | export { route as postApiV1ReposOwnerNameBuildsLabelShots } from "./postApiV1ReposOwnerNameBuildsLabelShots.ts"; |
| 261 | 263 | export { route as postApiV1ReposOwnerNameBuildsLabelShotsFile } from "./postApiV1ReposOwnerNameBuildsLabelShotsFile.ts"; |
| 262 | 264 | export { route as postApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./postApiV1ReposOwnerNameBuildsLabelShotsLooks.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNameBuildsLabelCart.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/builds/{label}/cart`, | |
| 6 | summary: "Name a pin's cart (rw scope, a person who writes): { name, version, page, showcase }, and start capturing its page from a build preview of its commit; answers 202 with the cart while the capture runs, 200 once it is kept", | |
| 7 | auth: true, | |
| 8 | status: 202, | |
| 9 | refusals: { | |
| 10 | forbidden: "403: not a person who writes the repository (an agent is refused)", | |
| 11 | not_found: "404: no such repository or pin, or the caller cannot read it", | |
| 12 | mirrored: "409: the repository mirrors an origin", | |
| 13 | moved: "409: the pin changed while it was written", | |
| 14 | invalid_demo: "422: a name over 60 or version over 40 characters, more than one line, or a page that is not a plain path", | |
| 15 | }, | |
| 16 | }; |
src/git/builds.ts +209 −13
| @@ -33,12 +33,60 @@ export interface BuildPin { | ||
| 33 | 33 | readonly pinnedBy: string; |
| 34 | 34 | /** ISO time it was pinned. */ |
| 35 | 35 | readonly pinnedAt: string; |
| 36 | /** The demo this pin is, when it names one. */ | |
| 37 | readonly demo?: Demo | undefined; | |
| 36 | 38 | /** Its notes, ranked, when it has any (`withNotes`); `note` is then the first one's text. */ |
| 37 | 39 | readonly notes?: readonly BuildNote[] | undefined; |
| 38 | 40 | /** The tag message's note, when `note` holds the first of its notes instead. */ |
| 39 | 41 | readonly tagNote?: string | undefined; |
| 40 | 42 | } |
| 41 | 43 | |
| 44 | /** | |
| 45 | * How a demo opens: `file` plays or downloads its captured HTML page, `env` | |
| 46 | * opens a running build preview of its commit, `shots` shows screenshots. | |
| 47 | */ | |
| 48 | export const LAUNCHES = ["file", "env", "shots"] as const; | |
| 49 | export type Launch = (typeof LAUNCHES)[number]; | |
| 50 | ||
| 51 | export const isLaunch = (value: unknown): value is Launch => | |
| 52 | typeof value === "string" && (LAUNCHES as readonly string[]).includes(value); | |
| 53 | ||
| 54 | /** A pin's demo: what a display shows and opens. */ | |
| 55 | export interface Demo { | |
| 56 | /** The demo it belongs to: pins sharing a name are one demo's builds. */ | |
| 57 | readonly name: string; | |
| 58 | readonly version: string; | |
| 59 | /** How it opens; unset follows the repository's `app.launch` (`launchOf`). */ | |
| 60 | readonly launch?: Launch | undefined; | |
| 61 | /** The page it opens, `/` or `/spicy`. */ | |
| 62 | readonly page: string; | |
| 63 | /** On display pages: chosen by a person, never by default. */ | |
| 64 | readonly showcase: boolean; | |
| 65 | /** An uploaded cover: a blob kept by `refs/covers/<label>`. */ | |
| 66 | readonly cover?: string | undefined; | |
| 67 | } | |
| 68 | ||
| 69 | export const COVERS_PREFIX = "refs/covers/"; | |
| 70 | ||
| 71 | /** A demo fact: one plain line, at most `max` characters. */ | |
| 72 | const isFact = (s: string, max: number): boolean => | |
| 73 | s.length > 0 && s.length <= max && !/[\x00-\x1f\x7f<>]/.test(s) && s.trim() === s; | |
| 74 | ||
| 75 | /** A page a demo may name: a path of plain segments. */ | |
| 76 | export const isDemoPage = (page: string): boolean => | |
| 77 | page.length <= 200 && /^\/(?:[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)?$/.test(page) && !page.includes(".."); | |
| 78 | ||
| 79 | /** Whether a demo's facts are all well formed. */ | |
| 80 | export function isDemo(demo: Demo): boolean { | |
| 81 | return ( | |
| 82 | isFact(demo.name, 60) && | |
| 83 | isFact(demo.version, 40) && | |
| 84 | (demo.launch === undefined || isLaunch(demo.launch)) && | |
| 85 | isDemoPage(demo.page) && | |
| 86 | (demo.cover === undefined || isSafeOid(demo.cover)) | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 42 | 90 | /** A label is short and plain: letters, digits, `.`, `_` and `-`. */ |
| 43 | 91 | export function isPinLabel(label: string): boolean { |
| 44 | 92 | return ( |
| @@ -62,6 +110,7 @@ export function isPinParent(parent: string): boolean { | ||
| 62 | 110 | |
| 63 | 111 | export type PinBuildError = |
| 64 | 112 | | "invalid_label" |
| 113 | | "invalid_demo" | |
| 65 | 114 | | "invalid_parent" |
| 66 | 115 | | "not_a_commit" |
| 67 | 116 | | "exists" |
| @@ -76,6 +125,7 @@ export interface PinInput { | ||
| 76 | 125 | readonly tagger: { readonly name: string; readonly email: string }; |
| 77 | 126 | /** Seconds since the epoch; tests pass one to order pins. */ |
| 78 | 127 | readonly at?: number; |
| 128 | readonly demo?: Demo | undefined; | |
| 79 | 129 | } |
| 80 | 130 | |
| 81 | 131 | /** One line, no control bytes: what a trailer or tagger field may hold. */ |
| @@ -95,9 +145,11 @@ export async function pinBuild( | ||
| 95 | 145 | if (kind.code !== 0 || kind.stdout.trim() !== "commit") |
| 96 | 146 | return err("not_a_commit"); |
| 97 | 147 | |
| 148 | if (input.demo !== undefined && !isDemo(input.demo)) return err("invalid_demo"); | |
| 149 | ||
| 98 | 150 | const at = input.at ?? Math.floor(Date.now() / 1000); |
| 99 | 151 | const note = input.note.replace(/\r/g, "").trim(); |
| 100 | const message = pinMessage(note === "" ? input.label : note, input.parent, oneLine(input.source) || "commit"); | |
| 152 | const message = pinMessage(note === "" ? input.label : note, input.parent, oneLine(input.source) || "commit", input.demo); | |
| 101 | 153 | const tag = |
| 102 | 154 | `object ${input.oid}\ntype commit\ntag ${input.label}\n` + |
| 103 | 155 | `tagger ${oneLine(input.tagger.name) || "forge"} <${oneLine(input.tagger.email)}> ${at} +0000\n\n` + |
| @@ -118,31 +170,158 @@ export async function pinBuild( | ||
| 118 | 170 | source: oneLine(input.source) || "commit", |
| 119 | 171 | pinnedBy: oneLine(input.tagger.name) || "forge", |
| 120 | 172 | pinnedAt: new Date(at * 1000).toISOString(), |
| 173 | demo: input.demo, | |
| 121 | 174 | }); |
| 122 | 175 | } |
| 123 | 176 | |
| 124 | /** A pin's message: its note, then its trailers. */ | |
| 125 | function pinMessage(note: string, parent: string | undefined, source: string): string { | |
| 126 | const trailers = [...(parent === undefined ? [] : [`Parent: ${parent}`]), `Source: ${source}`]; | |
| 177 | /** A pin's message: its note, then its trailers. Always the new trailers. */ | |
| 178 | function pinMessage(note: string, parent: string | undefined, source: string, demo: Demo | undefined): string { | |
| 179 | const trailers = [ | |
| 180 | ...(parent === undefined ? [] : [`Parent: ${parent}`]), | |
| 181 | `Source: ${source}`, | |
| 182 | ...(demo === undefined | |
| 183 | ? [] | |
| 184 | : [ | |
| 185 | `Demo: ${demo.name}`, | |
| 186 | `Version: ${demo.version}`, | |
| 187 | ...(demo.launch === undefined ? [] : [`Launch: ${demo.launch}`]), | |
| 188 | `Page: ${demo.page}`, | |
| 189 | ...(demo.showcase ? ["Showcase: on"] : []), | |
| 190 | ...(demo.cover === undefined ? [] : [`Cover: ${demo.cover}`]), | |
| 191 | ]), | |
| 192 | ]; | |
| 127 | 193 | return `${note}\n\n${trailers.join("\n")}\n`; |
| 128 | 194 | } |
| 129 | 195 | |
| 130 | const TRAILER = /^(Parent|Source): (.*)$/; | |
| 196 | /** Every trailer a pin may carry. */ | |
| 197 | const TRAILER = /^(Parent|Source|Demo|Cart|Version|Launch|Producer|Page|Showcase|Shelf|Cover): (.*)$/; | |
| 198 | ||
| 199 | /** What a pin's message carries, as `parsePinMessage` reads it. */ | |
| 200 | export interface PinMessage { | |
| 201 | readonly note: string; | |
| 202 | readonly parent: string | undefined; | |
| 203 | readonly source: string; | |
| 204 | readonly demo: Demo | undefined; | |
| 205 | /** Whether it carries any old trailer: `Cart:`, `Shelf:` or `Producer:`. */ | |
| 206 | readonly legacy: boolean; | |
| 207 | /** The old `Producer:`, when it carries one. */ | |
| 208 | readonly producer: string | undefined; | |
| 209 | } | |
| 131 | 210 | |
| 132 | 211 | /** The trailers block at the end of a pin's message, and the note above it. */ |
| 133 | function parsePinMessage(text: string): { note: string; parent: string | undefined; source: string } { | |
| 212 | export function parsePinMessage(text: string): PinMessage { | |
| 134 | 213 | const lines = text.replace(/\n+$/, "").split("\n"); |
| 135 | 214 | let parent: string | undefined; |
| 136 | 215 | let source = "commit"; |
| 216 | const facts = new Map<string, string>(); | |
| 137 | 217 | let end = lines.length; |
| 138 | 218 | while (end > 0) { |
| 139 | const m = TRAILER.exec(lines[end - 1]!); | |
| 219 | const line = lines[end - 1]!; | |
| 220 | const m = TRAILER.exec(line); | |
| 140 | 221 | if (m === null) break; |
| 141 | 222 | if (m[1] === "Parent" && isPinParent(m[2]!)) parent = m[2]!; |
| 142 | 223 | else if (m[1] === "Source") source = m[2]!; |
| 224 | else if (!facts.has(m[1]!)) facts.set(m[1]!, m[2]!); | |
| 143 | 225 | end--; |
| 144 | 226 | } |
| 145 | return { note: lines.slice(0, end).join("\n").trim(), parent, source }; | |
| 227 | // A new trailer wins over its old name when a tag carries both. An old | |
| 228 | // `Cart:` was always a captured page: with no `Launch:`, it is `file`. | |
| 229 | const name = facts.get("Demo") ?? facts.get("Cart"); | |
| 230 | const cover = facts.get("Cover"); | |
| 231 | const launch = facts.get("Launch") ?? (facts.has("Demo") || !facts.has("Cart") ? undefined : "file"); | |
| 232 | const demo: Demo | undefined = | |
| 233 | name === undefined | |
| 234 | ? undefined | |
| 235 | : { | |
| 236 | name, | |
| 237 | version: facts.get("Version") ?? "", | |
| 238 | ...(launch === undefined ? {} : { launch: launch as Launch }), | |
| 239 | page: facts.get("Page") ?? "/", | |
| 240 | showcase: (facts.get("Showcase") ?? facts.get("Shelf")) === "on", | |
| 241 | ...(cover === undefined ? {} : { cover }), | |
| 242 | }; | |
| 243 | return { | |
| 244 | note: lines.slice(0, end).join("\n").trim(), | |
| 245 | parent, | |
| 246 | source, | |
| 247 | demo: demo !== undefined && isDemo(demo) ? demo : undefined, | |
| 248 | legacy: facts.has("Cart") || facts.has("Shelf") || facts.has("Producer"), | |
| 249 | producer: facts.get("Producer"), | |
| 250 | }; | |
| 251 | } | |
| 252 | ||
| 253 | export type SetDemoError = "invalid_demo" | "not_found" | "moved" | "git_failed"; | |
| 254 | ||
| 255 | /** A pin's tag as `rewritePin` reads it: the tag object, its commit, its tagger line and its message. */ | |
| 256 | interface PinTag { | |
| 257 | readonly tag: string; | |
| 258 | readonly object: string; | |
| 259 | readonly tagger: { readonly name: string; readonly email: string; readonly at: string; readonly zone: string }; | |
| 260 | readonly message: PinMessage; | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Write a new tag object for the pin `label`'s commit, keeping its tagger, | |
| 265 | * date and message, with the demo `change` makes of what it holds. The ref | |
| 266 | * moves only from the tag read here, so two edits never cross. Notes are | |
| 267 | * never written here: they live on `refs/forge/notes`. | |
| 268 | */ | |
| 269 | async function rewritePin( | |
| 270 | dir: string, | |
| 271 | label: string, | |
| 272 | change: (old: PinTag) => Demo | undefined, | |
| 273 | ): Promise<Result<BuildPin, "not_found" | "moved" | "git_failed">> { | |
| 274 | if (!isPinLabel(label)) return err("not_found"); | |
| 275 | const ref = `${BUILDS_PREFIX}${label}`; | |
| 276 | const old = await runGitCapture(["rev-parse", "--verify", "--quiet", ref], { cwd: dir }); | |
| 277 | const oldTag = old.stdout.trim(); | |
| 278 | if (old.code !== 0 || !isSafeOid(oldTag)) return err("not_found"); | |
| 279 | const body = await runGit(["cat-file", "tag", oldTag], { cwd: dir }); | |
| 280 | if (!body.ok) return err("not_found"); | |
| 281 | const split = body.value.indexOf("\n\n"); | |
| 282 | const head = split === -1 ? body.value : body.value.slice(0, split); | |
| 283 | const tagger = /^tagger (.*) <([^>]*)> (\d+) ([+-]\d{4})$/m.exec(head); | |
| 284 | const object = /^object ([0-9a-f]+)$/m.exec(head)?.[1]; | |
| 285 | if (tagger === null || object === undefined) return err("not_found"); | |
| 286 | const message = parsePinMessage(split === -1 ? "" : body.value.slice(split + 2)); | |
| 287 | const demo = change({ tag: oldTag, object, tagger: { name: tagger[1]!, email: tagger[2]!, at: tagger[3]!, zone: tagger[4]! }, message }); | |
| 288 | const tag = | |
| 289 | `object ${object}\ntype commit\ntag ${label}\n` + | |
| 290 | `tagger ${tagger[1]} <${tagger[2]}> ${tagger[3]} ${tagger[4]}\n\n` + | |
| 291 | pinMessage(message.note === "" ? label : message.note, message.parent, message.source, demo); | |
| 292 | const made = await runGit(["mktag"], { cwd: dir, stdin: tag }); | |
| 293 | if (!made.ok) return err("git_failed"); | |
| 294 | const moved = await runGitCapture(["update-ref", ref, made.value.trim(), oldTag], { cwd: dir }); | |
| 295 | if (moved.code !== 0) return err("moved"); | |
| 296 | return ok({ | |
| 297 | label, | |
| 298 | oid: object, | |
| 299 | note: message.note, | |
| 300 | parent: message.parent, | |
| 301 | source: message.source, | |
| 302 | pinnedBy: tagger[1]!, | |
| 303 | pinnedAt: new Date(Number(tagger[3]) * 1000).toISOString(), | |
| 304 | demo, | |
| 305 | }); | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Give the pin `label` these demo facts, or none: a new tag object for the | |
| 310 | * same commit, with the same tagger, date, note, parent and source, and | |
| 311 | * only the new trailers. | |
| 312 | */ | |
| 313 | export async function setPinDemo( | |
| 314 | dir: string, | |
| 315 | label: string, | |
| 316 | demo: Demo | undefined, | |
| 317 | ): Promise<Result<BuildPin, SetDemoError>> { | |
| 318 | if (demo !== undefined && !isDemo(demo)) { | |
| 319 | if (!isPinLabel(label)) return err("not_found"); | |
| 320 | return err("invalid_demo"); | |
| 321 | } | |
| 322 | const done = await rewritePin(dir, label, () => demo); | |
| 323 | if (!done.ok) return done; | |
| 324 | return ok(withNotes(done.value, (await readBuildNotes(dir, [label])).get(label))); | |
| 146 | 325 | } |
| 147 | 326 | |
| 148 | 327 | /** The most bytes of one note's text. */ |
| @@ -410,8 +589,9 @@ export function coverType(bytes: Uint8Array): "image/png" | "image/jpeg" | "imag | ||
| 410 | 589 | export type DeletePinError = "not_found" | "moved" | "git_failed"; |
| 411 | 590 | |
| 412 | 591 | /** |
| 413 | * Delete the pin `label`: `refs/builds/<label>`, only from the tag read | |
| 414 | * here, so a pin changed meanwhile is not deleted. The commit stays. | |
| 592 | * Delete the pin `label`: `refs/builds/<label>`, with its cover and its | |
| 593 | * screenshots, in one ref transaction. The pin's ref goes only from the tag | |
| 594 | * read here, so a pin changed meanwhile is not deleted. The commit stays. | |
| 415 | 595 | */ |
| 416 | 596 | export async function deletePin(dir: string, label: string): Promise<Result<{ readonly oid: string; readonly tag: string }, DeletePinError>> { |
| 417 | 597 | if (!isPinLabel(label)) return err("not_found"); |
| @@ -425,9 +605,11 @@ export async function deletePin(dir: string, label: string): Promise<Result<{ re | ||
| 425 | 605 | if (tag === undefined) return err("not_found"); |
| 426 | 606 | const commit = await runGitCapture(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`], { cwd: dir }); |
| 427 | 607 | 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}`); | |
| 608 | // The pin's cover and curated screenshots (`git/shots.ts`) go with it, so a label pinned again starts bare. | |
| 609 | for (const other of [`${COVERS_PREFIX}${label}`, `refs/shots/${label}`]) { | |
| 610 | const at = await read(other); | |
| 611 | if (at !== undefined) lines.push(`delete ${other} ${at}`); | |
| 612 | } | |
| 431 | 613 | const done = await runGitCapture(["update-ref", "--stdin"], { cwd: dir, stdin: `start\n${lines.join("\n")}\nprepare\ncommit\n` }); |
| 432 | 614 | if (done.code !== 0) return err(/cannot lock|is at|expected/i.test(done.stderr) ? "moved" : "git_failed"); |
| 433 | 615 | return ok({ oid: commit.stdout.trim(), tag }); |
| @@ -439,6 +621,19 @@ async function readTagPin(dir: string, label: string): Promise<BuildPin | undefi | ||
| 439 | 621 | return (await listTagPins(dir, label))[0]; |
| 440 | 622 | } |
| 441 | 623 | |
| 624 | /** The largest cover a person may upload. */ | |
| 625 | export const COVER_MAX = 2 * 1024 * 1024; | |
| 626 | ||
| 627 | /** Store a cover picture as a blob kept by `refs/covers/<label>`; its id. */ | |
| 628 | export async function storeCover(dir: string, label: string, bytes: Uint8Array): Promise<Result<string, "invalid_cover" | "git_failed">> { | |
| 629 | if (!isPinLabel(label) || bytes.length > COVER_MAX || coverType(bytes) === undefined) return err("invalid_cover"); | |
| 630 | const blob = await runGit(["hash-object", "-w", "--stdin"], { cwd: dir, stdin: bytes }); | |
| 631 | if (!blob.ok) return err("git_failed"); | |
| 632 | const oid = blob.value.trim(); | |
| 633 | const kept = await runGitCapture(["update-ref", `${COVERS_PREFIX}${label}`, oid], { cwd: dir }); | |
| 634 | return kept.code === 0 ? ok(oid) : err("git_failed"); | |
| 635 | } | |
| 636 | ||
| 442 | 637 | /** |
| 443 | 638 | * Every pin, newest first, read in one `for-each-ref`; or the one `label`. |
| 444 | 639 | * A pin's note is its notes file on `refs/forge/notes` when it has one, |
| @@ -478,6 +673,7 @@ async function listTagPins(dir: string, label?: string): Promise<BuildPin[]> { | ||
| 478 | 673 | source: message.source, |
| 479 | 674 | pinnedBy: fields[i + 3]!, |
| 480 | 675 | pinnedAt: new Date(fields[i + 4]!).toISOString(), |
| 676 | demo: message.demo, | |
| 481 | 677 | }); |
| 482 | 678 | } |
| 483 | 679 | return pins; |
src/http/api.ts +2
| @@ -355,6 +355,7 @@ import { userRoutes } from "./api/user.ts"; | ||
| 355 | 355 | import { appRoutes } from "./api/app.ts"; |
| 356 | 356 | import { buildRoutes } from "./api/builds.ts"; |
| 357 | 357 | import { buildShotRoutes } from "./api/build_shots.ts"; |
| 358 | import { buildCartRoutes } from "./api/build_cart.ts"; | |
| 358 | 359 | import { iterationRoutes } from "./api/iterations.ts"; |
| 359 | 360 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 360 | 361 | import { reportRoutes } from "./api/reports.ts"; |
| @@ -3637,6 +3638,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3637 | 3638 | appRoutes(router); |
| 3638 | 3639 | buildRoutes(router); |
| 3639 | 3640 | buildShotRoutes(router); |
| 3641 | buildCartRoutes(router); | |
| 3640 | 3642 | iterationRoutes(router); |
| 3641 | 3643 | |
| 3642 | 3644 | egressGrantRoutes(router); |
src/http/api/build_cart.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * A pin's cart over the API (`actions/build_cart.ts`): what it is and how | |
| 3 | * its capture stands, to the repository's readers; naming it and starting | |
| 4 | * its capture needs the `rw` scope and a person who writes. | |
| 5 | */ | |
| 6 | ||
| 7 | import { cartOf, setCart, type Cart } from "../../actions/build_cart.ts"; | |
| 8 | import type { Repo } from "../../core/repos.ts"; | |
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import { loadRepo } from "../api.ts"; | |
| 11 | import type { Handler } from "../context.ts"; | |
| 12 | import { body, json, notFound, requireScope, respondAccess } from "../respond.ts"; | |
| 13 | import type { Router } from "../router.ts"; | |
| 14 | ||
| 15 | const STATUS: Record<string, number> = { mirrored: 409, moved: 409, invalid_demo: 422, git_failed: 500 }; | |
| 16 | ||
| 17 | /** A cart as JSON: where it plays once captured. */ | |
| 18 | export const cartJson = (repo: Repo, cart: Cart) => { | |
| 19 | const at = `/${repo.ownerName}/${repo.name}/builds/${encodeURIComponent(cart.label)}/cart`; | |
| 20 | return { | |
| 21 | label: cart.label, | |
| 22 | oid: cart.oid, | |
| 23 | demo: cart.demo ?? null, | |
| 24 | capture: cart.capture, | |
| 25 | play_url: cart.capture.state === "done" ? at : null, | |
| 26 | art_url: cart.capture.state === "done" ? `${at}.png` : null, | |
| 27 | }; | |
| 28 | }; | |
| 29 | ||
| 30 | const text = (v: unknown, max: number): string => (typeof v === "string" ? v.slice(0, max) : ""); | |
| 31 | ||
| 32 | export function buildCartRoutes(router: Router<Handler>): void { | |
| 33 | router.add("GET", "/api/v1/repos/:owner/:name/builds/:label/cart", async (ctx) => { | |
| 34 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 35 | const repo = loadRepo(ctx); | |
| 36 | if (!repo) return notFound(); | |
| 37 | const cart = await cartOf(ctx.app, ctx.auth.user, repo, ctx.params.label ?? ""); | |
| 38 | return cart.ok ? json(cartJson(repo, cart.value)) : notFound(); | |
| 39 | }); | |
| 40 | ||
| 41 | // Name the cart ({ name, version, page, showcase }) and start capturing its page: 202 while it runs. | |
| 42 | router.add("POST", "/api/v1/repos/:owner/:name/builds/:label/cart", async (ctx) => { | |
| 43 | const denied = requireScope(ctx, "rw"); | |
| 44 | if (denied) return denied; | |
| 45 | const repo = loadRepo(ctx); | |
| 46 | if (!repo) return notFound(); | |
| 47 | const b = await body(ctx.request); | |
| 48 | const done = await setCart(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "", { | |
| 49 | name: text(b.name, 200), | |
| 50 | version: text(b.version, 200), | |
| 51 | page: text(b.page, 400), | |
| 52 | showcase: b.showcase === true, | |
| 53 | }); | |
| 54 | if (!done.ok) return respondAccess(done.error, STATUS[done.error] ?? 422); | |
| 55 | return json(cartJson(repo, done.value), done.value.capture.state === "done" ? 200 : 202); | |
| 56 | }); | |
| 57 | } |
src/http/server.ts +4
| @@ -186,6 +186,7 @@ import { | ||
| 186 | 186 | import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts"; |
| 187 | 187 | import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts"; |
| 188 | 188 | import { shotImage } from "../web/pages/build_shots.ts"; |
| 189 | import { cartArt, cartPage } from "../web/pages/build_cart.ts"; | |
| 189 | 190 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; |
| 190 | 191 | import { |
| 191 | 192 | contributeSubmit, |
| @@ -1263,6 +1264,9 @@ export function buildRouter(): Router<Handler> { | ||
| 1263 | 1264 | router.add("GET", "/:owner/:name/builds/new", newBuildPage); |
| 1264 | 1265 | // A pin's screenshot, read: a tree kept by refs/shots/<label> (`git/shots.ts`). |
| 1265 | 1266 | router.add("GET", "/:owner/:name/builds/:label/shots/:file", shotImage); |
| 1267 | // A pin's cart: its captured page, played sandboxed, and its picture (`arcade/carts.ts`). | |
| 1268 | router.add("GET", "/:owner/:name/builds/:label/cart", cartPage); | |
| 1269 | router.add("GET", "/:owner/:name/builds/:label/cart.png", cartArt); | |
| 1266 | 1270 | router.add("GET", "/:owner/:name/iterations", iterationsPage); |
| 1267 | 1271 | router.add("POST", "/:owner/:name/builds", pinBuildSubmit); |
| 1268 | 1272 | router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit); |
src/tui/screens/builds.ts +25 −3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * which check again. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { setCart } from "../../actions/build_cart.ts"; | |
| 11 | 12 | import { deletePin, notePin, pinBuild } from "../../actions/builds.ts"; |
| 12 | 13 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; |
| 13 | 14 | import { listBuildPins, type BuildPin } from "../../git/builds.ts"; |
| @@ -38,6 +39,8 @@ const WORDS: Record<string, string> = { | ||
| 38 | 39 | moved: "That pin changed meanwhile; try again.", |
| 39 | 40 | mirrored: "A mirror's builds are its origin's.", |
| 40 | 41 | notes_lost: "The notes ref lost a note; nothing was written.", |
| 42 | invalid_demo: "A cart is a name (60), a version (40) and a plain page path.", | |
| 43 | git_failed: "Git refused the change.", | |
| 41 | 44 | }; |
| 42 | 45 | const say = (error: string): string => WORDS[error] ?? "Only a person who writes this repository can do that."; |
| 43 | 46 | |
| @@ -94,6 +97,24 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 94 | 97 | }), |
| 95 | 98 | ); |
| 96 | 99 | }; |
| 100 | const nameCart = (picked: BuildPin): void => { | |
| 101 | ctx.push( | |
| 102 | promptScreen(ctx, screen, { | |
| 103 | title: `Cart for ${picked.label}`, | |
| 104 | label: "name, version, then the page it opens", | |
| 105 | value: picked.demo === undefined ? `${repo.name} ${picked.label} /` : `${picked.demo.name} ${picked.demo.version} ${picked.demo.page}`, | |
| 106 | onSubmit: (entry) => { | |
| 107 | const [name = "", version = "", page = "/"] = entry.trim().split(/\s+/); | |
| 108 | if (name === "" || version === "") return "Give a name and a version."; | |
| 109 | return ctx.track( | |
| 110 | setCart(f.app, f.user, repo, picked.label, { name, version, page, showcase: picked.demo?.showcase ?? false }).then((r) => | |
| 111 | done(r.ok ? (r.value.capture.state === "done" ? `Cart ${name} is captured.` : `Capturing ${name} from a preview of ${picked.label}.`) : say(r.error)), | |
| 112 | ), | |
| 113 | ); | |
| 114 | }, | |
| 115 | }), | |
| 116 | ); | |
| 117 | }; | |
| 97 | 118 | const ask = (picked: BuildPin): void => { |
| 98 | 119 | ctx.push( |
| 99 | 120 | confirmScreen(ctx, screen, { |
| @@ -115,7 +136,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 115 | 136 | return [ |
| 116 | 137 | binding(["j", "k"], "move"), |
| 117 | 138 | binding(["s"], "screenshots"), |
| 118 | ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["d"], "delete")] : []), | |
| 139 | ...(mayPin(f, repo) ? [binding(["p"], "pin"), binding(["n"], "note"), binding(["c"], "cart"), binding(["d"], "delete")] : []), | |
| 119 | 140 | binding(["esc", "q"], "back"), |
| 120 | 141 | ]; |
| 121 | 142 | }, |
| @@ -142,7 +163,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 142 | 163 | const picked = from + i === state.index; |
| 143 | 164 | s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right); |
| 144 | 165 | const first = p.note.split("\n")[0] ?? ""; |
| 145 | line(i, `${p.label} ${metaOf(p.oid.slice(0, 7), p.pinnedBy, ago(p.pinnedAt), first === p.label ? "" : first)}`, picked ? c.fg : c.muted); | |
| 166 | line(i, `${p.label} ${metaOf(p.oid.slice(0, 7), p.pinnedBy, ago(p.pinnedAt), first === p.label ? "" : first)}${p.demo === undefined ? "" : ` ▶ ${p.demo.name} ${p.demo.version}`}`, picked ? c.fg : c.muted); | |
| 146 | 167 | } |
| 147 | 168 | }, |
| 148 | 169 | key(e) { |
| @@ -155,6 +176,7 @@ export function buildsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 155 | 176 | else if (e.key === "s" && !hidden && picked !== undefined) ctx.push(shotsScreen(f, ctx, repo, picked)); |
| 156 | 177 | else if (e.key === "p" && write) void ctx.track(pin()); |
| 157 | 178 | else if (e.key === "n" && write && picked !== undefined) addNote(picked); |
| 179 | else if (e.key === "c" && write && picked !== undefined) nameCart(picked); | |
| 158 | 180 | else if (e.key === "d" && write && picked !== undefined) ask(picked); |
| 159 | 181 | else return false; |
| 160 | 182 | ctx.invalidate(); |
| @@ -171,7 +193,7 @@ export function buildsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Co | ||
| 171 | 193 | { |
| 172 | 194 | id: "builds", |
| 173 | 195 | title: `${repo.ownerName}/${repo.name} — Builds`, |
| 174 | hint: "its pinned builds; `s` shows a pin's screenshots; a writer pins, notes and deletes", | |
| 196 | hint: "its pinned builds; `s` shows a pin's screenshots; a writer pins, notes, names a cart (`c`) and deletes", | |
| 175 | 197 | run: () => ctx.push(buildsScreen(f, ctx, repo)), |
| 176 | 198 | }, |
| 177 | 199 | ]; |
src/web/pages/build_cart.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * A pin's cart, played: its captured page and its picture | |
| 3 | * (`arcade/carts.ts`), to whoever reads the repository. The page is a | |
| 4 | * build's own HTML, so it is served sandboxed — an opaque origin that may | |
| 5 | * run its scripts and reach nothing of the forge's: no cookie, no storage, | |
| 6 | * no request back with the viewer's session. | |
| 7 | */ | |
| 8 | ||
| 9 | import { cartOf } from "../../actions/build_cart.ts"; | |
| 10 | import { readArt, readCapture } from "../../arcade/carts.ts"; | |
| 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 12 | import type { Ctx } from "../../http/context.ts"; | |
| 13 | import { loadRepo, notFoundPage } from "../chrome.ts"; | |
| 14 | ||
| 15 | /** What a played cart may do: run its own scripts, styles and pictures inline, nothing else, nowhere else. */ | |
| 16 | export const CART_POLICY = | |
| 17 | "sandbox allow-scripts allow-pointer-lock; default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:; worker-src blob:; form-action 'none'; frame-ancestors 'self'"; | |
| 18 | ||
| 19 | async function cartBytes(ctx: Ctx, art: boolean): Promise<Response> { | |
| 20 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 21 | const repo = loadRepo(ctx); | |
| 22 | if (!repo) return notFoundPage(ctx); | |
| 23 | const cart = await cartOf(ctx.app, ctx.auth.user, repo, ctx.params.label ?? ""); | |
| 24 | if (!cart.ok || cart.value.demo === undefined) return notFoundPage(ctx); | |
| 25 | const { oid, demo } = cart.value; | |
| 26 | const bytes = (art ? readArt : readCapture)(ctx.app.config.dataDir, repo, oid, demo.page); | |
| 27 | if (bytes === undefined) return notFoundPage(ctx); | |
| 28 | // A capture is written once per commit and page: the bytes never change. | |
| 29 | const etag = `"${oid.slice(0, 16)}${art ? "-art" : ""}"`; | |
| 30 | const headers = { | |
| 31 | "content-type": art ? "image/png" : "text/html; charset=utf-8", | |
| 32 | "x-content-type-options": "nosniff", | |
| 33 | "content-security-policy": art ? "default-src 'none'; sandbox" : CART_POLICY, | |
| 34 | "referrer-policy": "no-referrer", | |
| 35 | etag, | |
| 36 | "cache-control": "private, no-cache", | |
| 37 | }; | |
| 38 | if (ctx.request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers }); | |
| 39 | return new Response(bytes, { headers }); | |
| 40 | } | |
| 41 | ||
| 42 | // GET /:owner/:name/builds/:label/cart — the captured page, played sandboxed. | |
| 43 | export const cartPage = (ctx: Ctx): Promise<Response> => cartBytes(ctx, false); | |
| 44 | // GET /:owner/:name/builds/:label/cart.png — its picture, the cover's art. | |
| 45 | export const cartArt = (ctx: Ctx): Promise<Response> => cartBytes(ctx, true); |
src/web/pages/builds.ts +6
| @@ -107,6 +107,12 @@ export function buildsCard( | ||
| 107 | 107 | <span class="muted small build-meta" |
| 108 | 108 | >${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}</span |
| 109 | 109 | > |
| 110 | ${when( | |
| 111 | n.from === undefined && n.pin.demo?.launch === "file", | |
| 112 | () => html`<a class="build-cart small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/cart" title="Play the captured page of ${n.pin.demo?.name ?? ""}" | |
| 113 | >▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a | |
| 114 | >`, | |
| 115 | )} | |
| 110 | 116 | ${when( |
| 111 | 117 | canPreview && n.from === undefined, |
| 112 | 118 | () => html`<form method="post" action="${base}/builds/preview" class="build-preview"> |
test/arcade-carts.test.ts added +199
| @@ -0,0 +1,199 @@ | ||
| 1 | /** | |
| 2 | * Arcade carts: a cart built outside the forge is imported as a pin that | |
| 3 | * carries its demo, with its page and picture kept once, and its cover | |
| 4 | * kept by the repository. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { existsSync } from "node:fs"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { captureCart, captureState, capturePath, importCapture, keepPage, readArt, readCapture, startCapture } from "../src/arcade/carts.ts"; | |
| 14 | import { importCart } from "../src/arcade/import.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { deletePin, listBuildPins, parsePinMessage, readBuildPin, setPinDemo } from "../src/git/builds.ts"; | |
| 17 | import { repoDir } from "../src/git/repos.ts"; | |
| 18 | ||
| 19 | const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5]); | |
| 20 | const HTML = new TextEncoder().encode("<!doctype html><title>kart</title>"); | |
| 21 | const tagger = { name: "Jay Moran", email: "[email protected]" }; | |
| 22 | ||
| 23 | describe("arcade carts", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let sha = ""; | |
| 27 | let dir = ""; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-carts-")); | |
| 31 | app = createApp({ dataDir, port: 0 }); | |
| 32 | const user = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 33 | if (!user.ok) throw new Error(user.error); | |
| 34 | const alice = user.value; | |
| 35 | const made = await createRepo(app, alice, { name: "karts", visibility: "public" }); | |
| 36 | if (!made.ok) throw new Error(String(made.error)); | |
| 37 | const c = await commitToBranch(app, alice, made.value, { | |
| 38 | branch: "main", message: "one", files: [{ path: "a.txt", content: "a\n" }], | |
| 39 | }); | |
| 40 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 41 | sha = c.value.commitOid; | |
| 42 | dir = repoDir(app.reposRoot, "alice", "karts"); | |
| 43 | }); | |
| 44 | ||
| 45 | afterAll(async () => { | |
| 46 | app.close(); | |
| 47 | await rm(dataDir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | const entry = (over: object = {}) => ({ | |
| 51 | repo: "alice/karts", label: "iter-1", sha, note: "first", | |
| 52 | cart: { name: "fiesta-kart", version: "1.2", page: "/", shelf: true }, | |
| 53 | html: HTML, cover: PNG, art: PNG, ...over, | |
| 54 | }); | |
| 55 | ||
| 56 | test("an import pins the commit with its demo and keeps the page, picture and cover", async () => { | |
| 57 | const done = await importCart(dataDir, entry(), tagger); | |
| 58 | expect(done.ok && done.value).toEqual({ label: "iter-1", pinned: true, captured: true }); | |
| 59 | const pin = await readBuildPin(dir, "iter-1"); | |
| 60 | expect(pin?.oid).toBe(sha); | |
| 61 | expect(pin?.pinnedBy).toBe("Jay Moran"); | |
| 62 | expect(pin?.demo).toMatchObject({ name: "fiesta-kart", version: "1.2", launch: "file", page: "/", showcase: true }); | |
| 63 | expect(pin?.demo?.cover).toMatch(/^[0-9a-f]{40,64}$/); | |
| 64 | expect(readCapture(dataDir, { ownerName: "alice", name: "karts" }, sha, "/")).toEqual(HTML); | |
| 65 | expect(readArt(dataDir, { ownerName: "alice", name: "karts" }, sha, "/")).toEqual(PNG); | |
| 66 | }); | |
| 67 | ||
| 68 | test("importing again changes nothing: the page is written once", async () => { | |
| 69 | const again = await importCart(dataDir, entry({ html: new TextEncoder().encode("other") }), tagger); | |
| 70 | expect(again.ok && again.value).toEqual({ label: "iter-1", pinned: false, captured: false }); | |
| 71 | expect(readCapture(dataDir, { ownerName: "alice", name: "karts" }, sha, "/")).toEqual(HTML); | |
| 72 | expect((await listBuildPins(dir)).length).toBe(1); | |
| 73 | }); | |
| 74 | ||
| 75 | test("an import refuses what is not a repo, a commit, a cover or a page", async () => { | |
| 76 | expect((await importCart(dataDir, entry({ repo: "../x/y" }), tagger)).ok).toBe(false); | |
| 77 | expect((await importCart(dataDir, entry({ label: "iter-2", sha: "0".repeat(40) }), tagger)).ok).toBe(false); | |
| 78 | expect((await importCart(dataDir, entry({ label: "iter-3", cover: new Uint8Array([1, 2, 3]) }), tagger)).ok).toBe(false); | |
| 79 | expect((await importCart(dataDir, entry({ label: "iter-1", sha: "1".repeat(40) }), tagger)).ok).toBe(false); | |
| 80 | expect((await importCart(dataDir, entry({ label: "iter-4", cart: { name: "x", version: "1", page: "/../x", shelf: false } }), tagger)).ok).toBe(false); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a capture path cannot leave the carts directory", () => { | |
| 84 | const repo = { ownerName: "alice", name: "karts" }; | |
| 85 | expect(importCapture(dataDir, repo, sha, "/../../x", HTML).ok).toBe(false); | |
| 86 | expect(importCapture(dataDir, repo, "../../etc", "/", HTML).ok).toBe(false); | |
| 87 | expect(readCapture(dataDir, repo, "../../etc", "/")).toBeUndefined(); | |
| 88 | expect(existsSync(capturePath(dataDir, repo, sha, "/spicy"))).toBe(false); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a demo survives a rewrite of its pin, and goes when set to none; deleting drops the cover", async () => { | |
| 92 | const pin = (await readBuildPin(dir, "iter-1"))!; | |
| 93 | const hid = await setPinDemo(dir, "iter-1", { ...pin.demo!, showcase: false }); | |
| 94 | expect(hid.ok && hid.value.demo?.showcase).toBe(false); | |
| 95 | const back = (await readBuildPin(dir, "iter-1"))!; | |
| 96 | expect(back).toMatchObject({ oid: sha, note: "first", pinnedBy: "Jay Moran", source: "import" }); | |
| 97 | expect(back.demo?.showcase).toBe(false); | |
| 98 | expect((await setPinDemo(dir, "iter-1", { ...back.demo!, name: "bad\nname" })).ok).toBe(false); | |
| 99 | expect((await setPinDemo(dir, "nope", undefined)).ok).toBe(false); | |
| 100 | expect((await setPinDemo(dir, "iter-1", undefined)).ok).toBe(true); | |
| 101 | expect((await readBuildPin(dir, "iter-1"))?.demo).toBeUndefined(); | |
| 102 | expect((await deletePin(dir, "iter-1")).ok).toBe(true); | |
| 103 | }); | |
| 104 | ||
| 105 | test("old Cart: and Shelf: trailers still read as a demo", () => { | |
| 106 | const m = parsePinMessage("n\n\nSource: commit\nCart: old\nVersion: 3\nPage: /\nShelf: on\n"); | |
| 107 | expect(m.demo).toMatchObject({ name: "old", launch: "file", showcase: true }); | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | describe("arcade capture", () => { | |
| 112 | const { mkdtempSync, readFileSync: rf } = require("node:fs") as typeof import("node:fs"); | |
| 113 | const stub = Bun.serve({ | |
| 114 | port: 0, | |
| 115 | fetch: (r) => { | |
| 116 | const path = new URL(r.url).pathname; | |
| 117 | if (path === "/big") return new Response(new Uint8Array(2000)); | |
| 118 | if (path === "/gone") return new Response("no", { status: 404 }); | |
| 119 | return new Response("<p>game</p>"); | |
| 120 | }, | |
| 121 | }); | |
| 122 | const url = (p: string) => `http://127.0.0.1:${stub.port}${p}`; | |
| 123 | const picture = (calls: string[]) => async (input: { url: string; out: string }) => { | |
| 124 | calls.push(input.url); | |
| 125 | await Bun.write(input.out, PNG); | |
| 126 | return { ok: true, value: {} } as never; | |
| 127 | }; | |
| 128 | ||
| 129 | test("a page is kept once, with its picture taken by the stand-in", async () => { | |
| 130 | const base = join(mkdtempSync(join(tmpdir(), "forge-keep-")), "index"); | |
| 131 | const calls: string[] = []; | |
| 132 | expect((await keepPage(base, url("/"), picture(calls))).ok).toBe(true); | |
| 133 | expect(rf(`${base}.html`, "utf8")).toBe("<p>game</p>"); | |
| 134 | expect(new Uint8Array(rf(`${base}.png`))).toEqual(PNG); | |
| 135 | expect(calls).toEqual([url("/")]); | |
| 136 | expect(existsSync(`${base}.${process.pid}.shot.png`)).toBe(false); | |
| 137 | }); | |
| 138 | ||
| 139 | test("a missing page, a page over the cap, or a dead server keeps nothing", async () => { | |
| 140 | const dir = mkdtempSync(join(tmpdir(), "forge-keep-")); | |
| 141 | const none = picture([]); | |
| 142 | expect((await keepPage(join(dir, "a"), url("/gone"), none)).ok).toBe(false); | |
| 143 | expect((await keepPage(join(dir, "b"), url("/big"), none, "", "", "", 1000)).ok).toBe(false); | |
| 144 | expect((await keepPage(join(dir, "c"), "http://127.0.0.1:1/", none)).ok).toBe(false); | |
| 145 | expect(existsSync(join(dir, "a.html")) || existsSync(join(dir, "b.html")) || existsSync(join(dir, "c.html"))).toBe(false); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a failed picture still keeps the page", async () => { | |
| 149 | const base = join(mkdtempSync(join(tmpdir(), "forge-keep-")), "index"); | |
| 150 | const failed = (async () => ({ ok: false, error: "no chromium" })) as never; | |
| 151 | expect((await keepPage(base, url("/"), failed)).ok).toBe(true); | |
| 152 | expect(existsSync(`${base}.html`)).toBe(true); | |
| 153 | expect(existsSync(`${base}.png`)).toBe(false); | |
| 154 | }); | |
| 155 | ||
| 156 | const fakeApp = () => createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-cap-")), port: 0 }); | |
| 157 | const repo = { id: 1, ownerName: "alice", name: "karts" } as never; | |
| 158 | const OID = "a".repeat(40); | |
| 159 | ||
| 160 | test("a capture starts the build when it is not running, waits for it, keeps the page and its picture", async () => { | |
| 161 | const app = fakeApp(); | |
| 162 | let state = "stopped"; | |
| 163 | let starts = 0; | |
| 164 | const build = { | |
| 165 | up: () => ({ state, port: stub.port }), | |
| 166 | start: async () => { | |
| 167 | starts++; | |
| 168 | setTimeout(() => (state = "running"), 30); | |
| 169 | return { ok: true, value: undefined } as const; | |
| 170 | }, | |
| 171 | }; | |
| 172 | const calls: string[] = []; | |
| 173 | const run = startCapture(app, undefined, repo, OID, "/", { build, picture: picture(calls), pollMs: 5 }); | |
| 174 | expect(captureState(app, repo, OID, "/")).toEqual({ state: "capturing" }); | |
| 175 | expect((await run).ok).toBe(true); | |
| 176 | expect(starts).toBe(1); | |
| 177 | expect(calls).toEqual([url("/")]); | |
| 178 | expect(captureState(app, repo, OID, "/")).toEqual({ state: "done" }); | |
| 179 | expect(readCapture(app.config.dataDir, repo, OID, "/")).toEqual(new TextEncoder().encode("<p>game</p>")); | |
| 180 | // Captured once: a second capture neither starts a build nor fetches. | |
| 181 | expect((await captureCart(app, undefined, repo, OID, "/", { build, picture: picture(calls) })).ok).toBe(true); | |
| 182 | expect(starts).toBe(1); | |
| 183 | app.close(); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a build that fails, or never comes up, is a failed capture the state remembers", async () => { | |
| 187 | const app = fakeApp(); | |
| 188 | const failed = { up: () => ({ state: "failed" }), start: async () => ({ ok: true, value: undefined }) as const }; | |
| 189 | expect(await startCapture(app, undefined, repo, OID, "/", { build: failed, pollMs: 5 })).toEqual({ ok: false, error: "build_failed" }); | |
| 190 | expect(captureState(app, repo, OID, "/")).toMatchObject({ state: "failed", error: "build_failed" }); | |
| 191 | const slow = { up: () => ({ state: "starting" }), start: async () => ({ ok: true, value: undefined }) as const }; | |
| 192 | expect(await captureCart(app, undefined, repo, OID, "/spicy", { build: slow, waitMs: 20, pollMs: 5 })).toEqual({ ok: false, error: "timed_out" }); | |
| 193 | const gone = { up: () => ({ state: "running", port: stub.port }), start: async () => ({ ok: true, value: undefined }) as const }; | |
| 194 | expect(await captureCart(app, undefined, repo, OID, "/gone", { build: gone, picture: picture([]) })).toEqual({ ok: false, error: "page_missing" }); | |
| 195 | // A repository that is no app has no build to capture from. | |
| 196 | expect(await captureCart(app, undefined, repo, OID, "/")).toEqual({ ok: false, error: "not_an_app" }); | |
| 197 | app.close(); | |
| 198 | }); | |
| 199 | }); |
test/build-cart.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * A pin's cart through its doors: the API names it and starts its capture, | |
| 3 | * the builds page links a captured cart, and the cart plays sandboxed to the | |
| 4 | * repository's readers only. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { importCapture } from "../src/arcade/carts.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5]); | |
| 19 | ||
| 20 | describe("a pin's cart", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let base: string; | |
| 25 | const tokens: Record<string, string> = {}; | |
| 26 | let oid = ""; | |
| 27 | ||
| 28 | const api = (path: string, who?: string, init: RequestInit = {}) => | |
| 29 | fetch(`${base}/api/v1/repos/alice/${path}`, { | |
| 30 | ...init, | |
| 31 | headers: { ...(who ? { authorization: `Bearer ${tokens[who]}` } : {}), "content-type": "application/json" }, | |
| 32 | }); | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-cart-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | base = servers.url; | |
| 39 | for (const name of ["alice", "bob"]) { | |
| 40 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 41 | if (!made.ok) throw new Error(made.error); | |
| 42 | tokens[name] = createToken(app.db, getUserByName(app.db, name)!.id, "t", "rw").token; | |
| 43 | } | |
| 44 | const alice = getUserByName(app.db, "alice")!; | |
| 45 | for (const [name, visibility] of [["karts", "public"], ["secret", "private"]] as const) { | |
| 46 | const made = await createRepo(app, alice, { name, visibility }); | |
| 47 | if (!made.ok) throw new Error(String(made.error)); | |
| 48 | const c = await commitToBranch(app, alice, made.value, { message: "one", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 49 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 50 | oid = c.value.commitOid; | |
| 51 | const pinned = await pinBuild(app, alice, made.value, { label: "v1", oid }); | |
| 52 | if (!pinned.ok) throw new Error(pinned.error); | |
| 53 | } | |
| 54 | }); | |
| 55 | ||
| 56 | afterAll(async () => { | |
| 57 | servers.stop(); | |
| 58 | app.close(); | |
| 59 | await rm(dataDir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a pin with no cart says so", async () => { | |
| 63 | const res = await api("karts/builds/v1/cart"); | |
| 64 | expect(res.status).toBe(200); | |
| 65 | expect(await res.json()).toMatchObject({ label: "v1", demo: null, capture: { state: "none" }, play_url: null }); | |
| 66 | expect((await api("karts/builds/nope/cart")).status).toBe(404); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a writer names the cart and its capture starts; a reader, a stranger and a bad name are refused", async () => { | |
| 70 | const send = (who: string | undefined, b: object) => api("karts/builds/v1/cart", who, { method: "POST", body: JSON.stringify(b) }); | |
| 71 | const cart = { name: "fiesta-kart", version: "1", page: "/", showcase: true }; | |
| 72 | expect((await send(undefined, cart)).status).toBe(401); | |
| 73 | expect((await send("bob", cart)).status).toBe(403); | |
| 74 | expect((await send("alice", { ...cart, name: "two\nlines" })).status).toBe(422); | |
| 75 | expect((await send("alice", { ...cart, page: "/../x" })).status).toBe(422); | |
| 76 | const res = await send("alice", cart); | |
| 77 | expect(res.status).toBe(202); | |
| 78 | expect(await res.json()).toMatchObject({ demo: { name: "fiesta-kart", version: "1", launch: "file", page: "/", showcase: true } }); | |
| 79 | // karts is no app, so there is no build to capture from: the state says why. | |
| 80 | await Bun.sleep(50); | |
| 81 | expect(await (await api("karts/builds/v1/cart")).json()).toMatchObject({ capture: { state: "failed", error: "not_an_app" } }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a captured cart plays sandboxed, with its picture, and the builds page links it", async () => { | |
| 85 | importCapture(dataDir, { ownerName: "alice", name: "karts" }, oid, "/", new TextEncoder().encode("<p>kart</p>"), PNG); | |
| 86 | expect(await (await api("karts/builds/v1/cart")).json()).toMatchObject({ | |
| 87 | capture: { state: "done" }, | |
| 88 | play_url: "/alice/karts/builds/v1/cart", | |
| 89 | art_url: "/alice/karts/builds/v1/cart.png", | |
| 90 | }); | |
| 91 | const played = await fetch(`${base}/alice/karts/builds/v1/cart`); | |
| 92 | expect(played.status).toBe(200); | |
| 93 | expect(await played.text()).toBe("<p>kart</p>"); | |
| 94 | const policy = played.headers.get("content-security-policy") ?? ""; | |
| 95 | expect(policy).toStartWith("sandbox allow-scripts"); | |
| 96 | expect(policy).not.toContain("allow-same-origin"); | |
| 97 | expect(played.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 98 | const art = await fetch(`${base}/alice/karts/builds/v1/cart.png`); | |
| 99 | expect(art.headers.get("content-type")).toBe("image/png"); | |
| 100 | expect(new Uint8Array(await art.arrayBuffer())).toEqual(PNG); | |
| 101 | const page = await (await fetch(`${base}/alice/karts/builds`)).text(); | |
| 102 | expect(page).toContain('href="/alice/karts/builds/v1/cart"'); | |
| 103 | expect(page).toContain("fiesta-kart"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a private repository's cart is not found by a stranger", async () => { | |
| 107 | const send = await api("secret/builds/v1/cart", "alice", { method: "POST", body: JSON.stringify({ name: "hush", version: "1", page: "/", showcase: false }) }); | |
| 108 | expect(send.status).toBe(202); | |
| 109 | importCapture(dataDir, { ownerName: "alice", name: "secret" }, oid, "/", new TextEncoder().encode("<p>hush</p>")); | |
| 110 | expect((await api("secret/builds/v1/cart")).status).toBe(404); | |
| 111 | expect((await fetch(`${base}/alice/secret/builds/v1/cart`)).status).toBe(404); | |
| 112 | expect((await api("secret/builds/v1/cart", "alice")).status).toBe(200); | |
| 113 | }); | |
| 114 | }); |
test/security-floor-routes.test.ts +4
| @@ -50,11 +50,14 @@ 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 | 52 | const SHOT = "needs a pin with a picture kept by refs/shots; the seed pins nothing"; |
| 53 | const CART = "the seed pins no build with a cart (test/build-cart.test.ts covers its headers)"; | |
| 53 | 54 | const UNSEEDED: Record<string, string> = { |
| 54 | 55 | "/:owner/-/teams/:team": ORG, |
| 55 | 56 | "/talk/:id": TALK, |
| 56 | 57 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 57 | 58 | "/:owner/:name/bets/:id": BET, |
| 59 | "/:owner/:name/builds/:label/cart": CART, | |
| 60 | "/:owner/:name/builds/:label/cart.png": CART, | |
| 58 | 61 | "/:owner/:name/builds/:label/shots/:file": SHOT, |
| 59 | 62 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, |
| 60 | 63 | "/:owner/:name/milestones/:number/limb/:op": LIMB, |
| @@ -68,6 +71,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 68 | 71 | "/api/v1/orgs/:org": ORG, |
| 69 | 72 | "/api/v1/orgs/:org/teams": ORG, |
| 70 | 73 | "/api/v1/orgs/:org/teams/:team/members": ORG, |
| 74 | "/api/v1/repos/:owner/:name/builds/:label/cart": CART, | |
| 71 | 75 | "/api/v1/repos/:owner/:name/builds/:label/shots": SHOT, |
| 72 | 76 | "/api/v1/repos/:owner/:name/builds/:label/shots/looks": SHOT, |
| 73 | 77 | "/api/v1/repos/:owner/:name/gates/:id/log": GATE, |
new above
offline
Unrelated to the carts; fix #9193 in its own pull rather than allow-listing here.