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

App and pull OG cards embed the latest look screenshot #2275

Merged opened this

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

Merged

scout/forge:scout/2049-app-and-pull-og-cards-embed-the-latest-l → main 1 commit fork

  • Merged. scout/2049-app-and-pull-og-cards-embed-the-latest-l was merged into main as 35d1fc6.

Review

Sign in to review this change.
8 files changed +355 −11
README.md +10
@@ -920,6 +920,16 @@ takes back (`supersedes`). `/asked` shows "said again, first on …", "takes bac
920920 social cards. They show only what an anonymous reader can open; anything
921921 private, sealed or unknown is the same 404.
922922
923 An app card carries the app's latest look (the production thumb, or a desktop
924 or phone shot taken as nobody) beside the words; a pull card carries the
925 production look as "before" and the pull's preview look as "after" (the
926 preview alone if production has none, nothing if the pull has no look). Only
927 a look an anonymous reader may fetch is used, of a public app and never a
928 sealed pull's. The picture is decoded, reduced to fit 480×300 and embedded as
929 a data URI (no dependency; the forge's own PNG codec), and the look's id and
930 taking name the card, so a new look is a new card. A look whose file cannot be
931 read leaves the card as it was.
932
923933 Two of them carry a figure beside the words:
924934
925935 - **Map** — `/og/map/owner~name.svg`: the repository's dependency graph as a
src/web/og/card.ts +57 −1
@@ -7,6 +7,12 @@
77 * score (`score.ts`) — drawn in a box on the right; the words then keep to
88 * the left column, a little smaller, so the two never overlap.
99 *
10 * A card may instead carry pictures (a look, or a before and after pair) as
11 * data URIs, in the same box; the words keep to the left column as with a
12 * figure. A picture is drawn only if it is a base64 PNG data URI of
13 * reasonable size, and its `id` — not its bytes — is what names the card
14 * (`signature`), so a new look is a new card.
15 *
1016 * Every text is stripped of characters XML cannot carry, clamped to the
1117 * card's width by an estimated advance (system fonts are unmeasurable here,
1218 * so the estimate is deliberately wide), ended with an ellipsis, and only
@@ -28,12 +34,26 @@ export type Figure = GraphFigure | ScoreFigure;
2834
2935 export { escapeXml, fit };
3036
37 /** A picture on a card: `id` names it, `src` is the data URI, `path` where it is read from. */
38 export interface CardImage {
39 id: string;
40 label?: string;
41 path?: string;
42 src?: string;
43 }
44
3145 export interface CardInput {
3246 kind: CardKind;
3347 title: string;
3448 lines?: readonly string[];
3549 stat?: string;
3650 figure?: Figure;
51 images?: readonly CardImage[];
52 }
53
54 /** What names a card: its input, less the bytes of its pictures (their ids stand for them). */
55 export function signature(input: CardInput): string {
56 return JSON.stringify(input, (key, value) => (key === "src" || key === "path" ? undefined : value));
3757 }
3858
3959 const T = THEMES.dark.tokens;
@@ -114,6 +134,42 @@ function drawFigure(figure: unknown): string {
114134 return "";
115135 }
116136
137 const DATA_PNG = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/;
138 /** A picture longer than this (as a URI) is not drawn. */
139 const MAX_URI = 400_000;
140 const MAX_IMAGES = 2;
141 export const IMAGE_BOX = { x: 740, y: 125, w: 380, h: 380 } as const;
142 const LABEL_SIZE = 20;
143
144 /** The pictures' SVG, or nothing when there is no usable one. */
145 function drawImages(images: unknown): string {
146 const usable = (Array.isArray(images) ? images : [])
147 .filter(
148 (i): i is CardImage =>
149 typeof i?.src === "string" && i.src.length <= MAX_URI && DATA_PNG.test(i.src),
150 )
151 .slice(0, MAX_IMAGES);
152 if (usable.length === 0) return "";
153 const { x, y, w, h } = IMAGE_BOX;
154 const gap = 14;
155 const labelled = usable.length > 1;
156 const slot = labelled ? Math.floor((h - gap) / usable.length) - LABEL_SIZE - 6 : 240;
157 const out: string[] = [];
158 let top = labelled ? y : y + Math.floor((h - slot) / 2) - 40;
159 for (const image of usable) {
160 if (labelled) {
161 out.push(text(x, top + LABEL_SIZE, LABEL_SIZE, T["muted-fg"], clean(image.label), ` font-weight="700" letter-spacing="2"`));
162 top += LABEL_SIZE + 6;
163 }
164 out.push(
165 `<rect x="${x - 1}" y="${top - 1}" width="${w + 2}" height="${slot + 2}" rx="6" fill="${T.border}"/>`,
166 `<image href="${image.src}" x="${x}" y="${top}" width="${w}" height="${slot}" preserveAspectRatio="xMidYMid meet"/>`,
167 );
168 top += slot + gap;
169 }
170 return out.join("");
171 }
172
117173 export function renderCard(input: CardInput): string {
118174 const kind: CardKind = CARD_KINDS.includes(input?.kind) ? input.kind : "page";
119175 const color = KIND_COLOR[kind];
@@ -134,7 +190,7 @@ export function renderCard(input: CardInput): string {
134190 text(CARD_WIDTH - PAD, PAD + 24, 24, color, label, ` font-weight="700" text-anchor="end" letter-spacing="2"`),
135191 );
136192
137 const figure = drawFigure(input?.figure);
193 const figure = drawFigure(input?.figure) || drawImages(input?.images);
138194 const column = figure ? COLUMN : INNER;
139195 if (figure) out.push(figure);
140196
src/web/og/looks.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Which looks a card may carry. A card is public, so it carries only a
3 * picture an anonymous reader could fetch from the look route: taken as
4 * nobody (or a current thumb), of the home page, of a public app, and not
5 * of a sealed pull's preview. Returns plain `CardImage`s naming files;
6 * `embedShots` reads them once the card is known to be wanted.
7 */
8
9 import { join } from "node:path";
10 import { previewEnv } from "../../apps/data.ts";
11 import type { AppRecord } from "../../core/apps.ts";
12 import { DEFAULT_PAGE, looksForEnv, lookVersion, type Look } from "../../core/looks.ts";
13 import { sealedPreview } from "../../core/sealed.ts";
14 import { keepLook } from "../../looks/service.ts";
15 import type { App } from "../../app.ts";
16 import type { CardImage, CardInput } from "./card.ts";
17 import { shotUri } from "./shot.ts";
18
19 /** The newest home-page picture of an environment an anonymous reader may see. */
20 function visibleLook(app: App, record: AppRecord, repoId: string, env: string): Look | undefined {
21 if (record.access !== "public" || sealedPreview(app.db, repoId, env)) return undefined;
22 const home = looksForEnv(app.db, record.id, env).filter(
23 (l) => l.page === DEFAULT_PAGE && l.error === null && l.path !== "",
24 );
25 const thumb = home.find((l) => l.kind === "thumb" && keepLook(app.config.dataDir, l));
26 const taken = (kind: Look["kind"]) => home.find((l) => l.kind === kind && l.takenAs === "nobody");
27 return thumb ?? taken("desktop") ?? taken("phone");
28 }
29
30 const imageOf = (app: App, look: Look, label?: string): CardImage => ({
31 id: `${look.id}.${lookVersion(look)}`,
32 path: join(app.config.dataDir, look.path),
33 ...(label ? { label } : {}),
34 });
35
36 /** The app's latest look, as a list of none or one. */
37 export function appImages(app: App, record: AppRecord, repoId: string): CardImage[] {
38 const look = visibleLook(app, record, repoId, "production");
39 return look ? [imageOf(app, look)] : [];
40 }
41
42 /** A pull's preview look, after what production looked like before it; none without the former. */
43 export function pullImages(app: App, record: AppRecord, repoId: string, number: number): CardImage[] {
44 const after = visibleLook(app, record, repoId, previewEnv(number));
45 if (!after) return [];
46 const before = visibleLook(app, record, repoId, "production");
47 return before
48 ? [imageOf(app, before, "before"), imageOf(app, after, "after")]
49 : [imageOf(app, after)];
50 }
51
52 /** The input with its pictures read and reduced; one that cannot be read is left out. */
53 export function embedShots(input: CardInput): CardInput {
54 if (!input.images?.length) return input;
55 const images: CardImage[] = [];
56 for (const image of input.images) {
57 const src = image.path === undefined ? undefined : shotUri(image.path);
58 if (src) images.push({ ...image, src });
59 }
60 return { ...input, images };
61 }
src/web/og/png-cache.ts +3 −3
@@ -10,19 +10,19 @@
1010 import { createHash } from "node:crypto";
1111 import { mkdir, readdir, rename, stat, unlink, utimes, writeFile } from "node:fs/promises";
1212 import { join } from "node:path";
13 import type { CardInput } from "./card.ts";
13 import { signature, type CardInput } from "./card.ts";
1414 import type { Rasterizer } from "./raster.ts";
1515
1616 export const OG_PNG_CAP = 512;
1717 export const OG_PNG_QUEUE = 4;
1818 /** Bump when the card's look or the fonts change: old files are then unreachable. */
19 const LOOK = "1";
19 const LOOK = "2";
2020
2121 /** Thrown when too many cards wait; working as designed, not a failure. */
2222 export class Busy extends Error {}
2323
2424 export function cardKey(input: CardInput): string {
25 return createHash("sha256").update(`${LOOK}\n${JSON.stringify(input)}`).digest("hex");
25 return createHash("sha256").update(`${LOOK}\n${signature(input)}`).digest("hex");
2626 }
2727
2828 export interface PngCache {
src/web/og/shot.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * A look, made small enough to sit on a card: the picture is decoded,
3 * reduced to fit `SHOT_MAX`, re-encoded and returned as a data URI, so the
4 * card stays one self-contained SVG. A file that is missing, unreadable or
5 * not a PNG is no picture (undefined), never an error: the card is then the
6 * card it would have been without one.
7 */
8
9 import { readFileSync } from "node:fs";
10 import { thumbnail } from "../../design/image/image.ts";
11 import { decodePng, encodePng } from "../../design/image/png.ts";
12
13 /** What a picture is reduced to fit, in pixels: twice the box it is drawn in. */
14 export const SHOT_MAX = { width: 480, height: 300 } as const;
15 /** A file past this is not read at all. */
16 const MAX_FILE = 4 * 1024 * 1024;
17
18 export function shotUri(path: string): string | undefined {
19 try {
20 const bytes = new Uint8Array(readFileSync(path));
21 if (bytes.length > MAX_FILE) return undefined;
22 const decoded = decodePng(bytes);
23 if (!decoded.ok) return undefined;
24 const { width, height, rgba } = decoded.value;
25 const small = thumbnail(rgba, width, height, SHOT_MAX.width, SHOT_MAX.height);
26 const png = encodePng(small.width, small.height, small.rgba);
27 return `data:image/png;base64,${Buffer.from(png).toString("base64")}`;
28 } catch {
29 return undefined;
30 }
31 }
src/web/pages/og.ts +14 −6
@@ -21,7 +21,7 @@ import type { App } from "../../app.ts";
2121 import type { Run } from "../../core/runs.ts";
2222 import { getUserById } from "../../core/users.ts";
2323 import { getRepoRun } from "../../actions/runs.ts";
24 import { appsForRepos, getAppBySlug, isSlug } from "../../core/apps.ts";
24 import { appsForRepos, getAppByRepo, getAppBySlug, isSlug } from "../../core/apps.ts";
2525 import { audit } from "../../contract/audit.ts";
2626 import { latestProbes } from "../../contract/probes.ts";
2727 import { checkSummary, listActiveRuns } from "../../core/runs.ts";
@@ -42,7 +42,8 @@ import { isSafeName } from "../../git/guard.ts";
4242 import type { Ctx } from "../../http/context.ts";
4343 import { notFound } from "../../http/respond.ts";
4444 import { auditInput, type AuditRow } from "../og/audit.ts";
45 import { renderCard, type CardInput } from "../og/card.ts";
45 import { renderCard, signature, type CardInput } from "../og/card.ts";
46 import { appImages, embedShots, pullImages } from "../og/looks.ts";
4647 import { mapInput } from "../og/map.ts";
4748 import { marksFor } from "./map.ts";
4849 import { Busy, pngCache, type PngCache } from "../og/png-cache.ts";
@@ -207,7 +208,12 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined
207208 kind === "pull" ? getPull(db, at.repo.id, n) : getIssue(db, at.repo.id, n);
208209 if (!item || item.kind !== kind || !mayView(db, at.repo, item, undefined))
209210 return undefined;
210 if (kind === "pull") return pullInput(ctx, at.repo, item as Pull);
211 if (kind === "pull") {
212 const input = pullInput(ctx, at.repo, item as Pull);
213 const record = getAppByRepo(db, at.repo.id);
214 const images = record ? pullImages(ctx.app, record, at.repo.id, n) : [];
215 return images.length ? { ...input, images } : input;
216 }
211217 return {
212218 kind,
213219 title: item.title,
@@ -324,8 +330,10 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
324330 if (!repo) return undefined;
325331 const live = liveState(ctx, repo);
326332 const base = repoInput(ctx, repo, "app");
333 const images = appImages(ctx.app, record, repo.id);
327334 return {
328335 ...base,
336 ...(images.length ? { images } : {}),
329337 title: record.slug,
330338 lines: [
331339 ...(base.lines ?? []),
@@ -354,7 +362,7 @@ export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") {
354362 return async (ctx: Ctx): Promise<Response> => {
355363 const input = await resolve(ctx, kind, ctx.params.ref ?? "");
356364 if (!input) return notFound();
357 const etag = `"${createHash("sha256").update(JSON.stringify(input)).digest("hex")}${format === "png" ? "-png" : ""}"`;
365 const etag = `"${createHash("sha256").update(signature(input)).digest("hex")}${format === "png" ? "-png" : ""}"`;
358366 const headers = {
359367 etag,
360368 "cache-control": "public, max-age=300",
@@ -362,9 +370,9 @@ export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") {
362370 };
363371 if (ctx.request.headers.get("if-none-match") === etag)
364372 return new Response(null, { status: 304, headers });
365 if (format === "svg") return new Response(renderCard(input), { headers });
373 if (format === "svg") return new Response(renderCard(embedShots(input)), { headers });
366374 try {
367 return new Response(await cacheFor(ctx.app).get(input), { headers });
375 return new Response(await cacheFor(ctx.app).get(embedShots(input)), { headers });
368376 } catch (error) {
369377 if (!(error instanceof Busy))
370378 ctx.app.log.error("og card could not be rasterized", { kind, error: String(error) });
test/og-card.test.ts +49 −1
@@ -1,5 +1,5 @@
11 import { describe, expect, test } from "bun:test";
2 import { CARD_KINDS, escapeXml, fit, renderCard } from "../src/web/og/card.ts";
2 import { CARD_KINDS, escapeXml, fit, renderCard, signature } from "../src/web/og/card.ts";
33
44 /** Minimal well-formedness check: balanced tags, no stray `<`/`&` in text. */
55 function wellFormed(svg: string): void {
@@ -72,3 +72,51 @@ describe("og card", () => {
7272 expect(escapeXml(`<&>"'`)).toBe("&lt;&amp;&gt;&quot;&#39;");
7373 });
7474 });
75
76 describe("og card pictures", () => {
77 const src = "data:image/png;base64,iVBORw0KGgo=";
78 const base = { kind: "app", title: "peek", lines: ["a line"], stat: "1 issues" } as const;
79
80 test("a picture is drawn beside the words, which keep to their column", () => {
81 const plain = renderCard(base);
82 const svg = renderCard({ ...base, images: [{ id: "a", src }] });
83 wellFormed(svg);
84 expect(plain).not.toContain("<image");
85 expect(svg).toContain(`<image href="${src}"`);
86 expect(svg).toContain("peek");
87 });
88
89 test("two pictures are labelled before and after", () => {
90 const svg = renderCard({
91 ...base,
92 images: [
93 { id: "a", src, label: "before" },
94 { id: "b", src, label: "after" },
95 ],
96 });
97 wellFormed(svg);
98 expect(svg.match(/<image /g)).toHaveLength(2);
99 expect(svg).toContain(">before<");
100 expect(svg).toContain(">after<");
101 });
102
103 test("only a base64 PNG data URI is drawn", () => {
104 for (const bad of [
105 "https://example.com/x.png",
106 'data:image/png;base64,AAA" onload="x',
107 "data:image/svg+xml;base64,AAAA",
108 "data:image/png;base64," + "A".repeat(500_000),
109 "",
110 ]) {
111 const svg = renderCard({ ...base, images: [{ id: "a", src: bad }] });
112 expect(svg).toBe(renderCard(base));
113 }
114 });
115
116 test("the signature names a picture by id, not by bytes", () => {
117 const a = signature({ ...base, images: [{ id: "a", src, path: "/x" }] });
118 expect(signature({ ...base, images: [{ id: "a", src: "other", path: "/y" }] })).toBe(a);
119 expect(signature({ ...base, images: [{ id: "b", src }] })).not.toBe(a);
120 expect(a).not.toBe(signature(base));
121 });
122 });
test/og-png.test.ts +130
@@ -1,10 +1,15 @@
11 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdirSync, rmSync, writeFileSync } from "node:fs";
23 import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
34 import { tmpdir } from "node:os";
45 import { join } from "node:path";
56 import { createApp as createForge, type App } from "../src/app.ts";
67 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp } from "../src/core/apps.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { recordLook } from "../src/core/looks.ts";
711 import { createUser } from "../src/core/users.ts";
12 import { encodePng } from "../src/design/image/png.ts";
813 import { serve, type Servers } from "../src/http/server.ts";
914 import { renderCard } from "../src/web/og/card.ts";
1015 import { cardKey, pngCache } from "../src/web/og/png-cache.ts";
@@ -171,3 +176,128 @@ describe("og png", () => {
171176 }
172177 });
173178 });
179
180 describe("og cards with a look", () => {
181 let dataDir: string;
182 let app: App;
183 let servers: Servers;
184 let base: string;
185 let appId = "";
186 let owner: { id: string };
187
188 /** A 1280×800 page-like picture: bands and blocks, so it compresses as a screenshot does. */
189 const screenshot = (hue: number): Uint8Array => {
190 const w = 1280;
191 const h = 800;
192 const rgba = new Uint8Array(w * h * 4);
193 for (let y = 0; y < h; y++)
194 for (let x = 0; x < w; x++) {
195 const o = (y * w + x) * 4;
196 const block = ((x >> 5) + (y >> 5)) % 3;
197 rgba[o] = (hue + block * 60 + (y >> 3)) & 255;
198 rgba[o + 1] = 40 + block * 50;
199 rgba[o + 2] = 200 - (x >> 3);
200 rgba[o + 3] = 255;
201 }
202 return encodePng(w, h, rgba);
203 };
204
205 const look = (env: string, kind: "thumb" | "desktop", takenAs: "owner" | "nobody", hue: number) => {
206 const path = join("looks", appId, env, `${hue}-${kind}.png`);
207 mkdirSync(join(dataDir, "looks", appId, env), { recursive: true });
208 writeFileSync(join(dataDir, path), screenshot(hue));
209 return recordLook(app.db, { appId, env, commitOid: "a".repeat(40), kind, path, takenAs });
210 };
211
212 beforeAll(async () => {
213 dataDir = await mkdtemp(join(tmpdir(), "forge-ogshot-"));
214 app = createForge({ dataDir, port: 0 });
215 servers = await serve(app);
216 base = servers.url;
217 const u = await createUser(app.db, {
218 trusted: true,
219 username: "alice",
220 email: "[email protected]",
221 password: "alice-passphrase",
222 adminIfFirst: true,
223 });
224 if (!u.ok) throw new Error(u.error);
225 owner = u.value;
226 const made = await createRepo(app, u.value, { name: "peek", visibility: "public" });
227 if (!made.ok) throw new Error(String(made.error));
228 const rec = createApp(app.db, { repoId: made.value.id, slug: "peek-app", access: "public" });
229 if (!rec.ok) throw new Error(rec.error);
230 appId = rec.value.id;
231 const issue = createIssue(app.db, {
232 repoId: made.value.id,
233 authorId: u.value.id,
234 title: "A pull with looks",
235 kind: "pull",
236 });
237 if (!issue.ok) throw new Error(String(issue.error));
238 app.db
239 .query(
240 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
241 VALUES (?1, 'main', 'topic', '', '', ?2)`,
242 )
243 .run(issue.value.id, made.value.id);
244 });
245
246 afterAll(async () => {
247 await servers.stop();
248 await rm(dataDir, { recursive: true, force: true });
249 });
250
251 const images = (svg: string) => svg.match(/<image /g)?.length ?? 0;
252 const text = async (path: string) => (await fetch(`${base}${path}`)).text();
253
254 test("without a look the app card is the card it was", async () => {
255 expect(images(await text("/og/app/peek-app.svg"))).toBe(0);
256 });
257
258 test("a look taken as the owner is not on the card; one taken as nobody is, and a new look is a new card", async () => {
259 look("production", "desktop", "owner", 1);
260 expect(images(await text("/og/app/peek-app.svg"))).toBe(0);
261 look("production", "desktop", "nobody", 2);
262 const first = await fetch(`${base}/og/app/peek-app.svg`);
263 expect(images(await first.text())).toBe(1);
264 const etag = first.headers.get("etag")!;
265 expect((await fetch(`${base}/og/app/peek-app.svg`, { headers: { "if-none-match": etag } })).status).toBe(304);
266
267 // A retake of the same slot is a new taking, hence a new card.
268 await Bun.sleep(5);
269 look("production", "desktop", "nobody", 3);
270 const second = await fetch(`${base}/og/app/peek-app.svg`);
271 expect(second.headers.get("etag")).not.toBe(etag);
272 await second.text();
273 });
274
275 test("the PNG twin carries the picture and stays small", async () => {
276 const bare = await fetch(`${base}/og/repo/alice~peek.png`);
277 const res = await fetch(`${base}/og/app/peek-app.png`);
278 expect(res.status).toBe(200);
279 const bytes = new Uint8Array(await res.arrayBuffer());
280 expect([...bytes.slice(0, 4)]).toEqual(PNG);
281 expect(bytes.length).toBeLessThan(300 * 1024);
282 // The picture's colour is in the right half of the card; the plain card's is not.
283 expect(bytes.length).toBeGreaterThan(new Uint8Array(await bare.arrayBuffer()).length);
284 });
285
286 test("a pull card carries before and after, and a sealed or absent look nothing", async () => {
287 expect(images(await text("/og/pull/alice~peek~1.svg"))).toBe(0);
288 look("pull-1", "desktop", "nobody", 4);
289 const svg = await text("/og/pull/alice~peek~1.svg");
290 expect(images(svg)).toBe(2);
291 expect(svg).toContain(">before<");
292 expect(svg).toContain(">after<");
293 app.db.query("UPDATE issue SET sealed = 1 WHERE kind = 'pull'").run();
294 expect((await fetch(`${base}/og/pull/alice~peek~1.svg`)).status).toBe(404);
295 });
296
297 test("a look whose file is gone leaves the plain card", async () => {
298 app.db.query("DELETE FROM look").run();
299 look("production", "desktop", "nobody", 5);
300 rmSync(join(dataDir, "looks", appId, "production", "5-desktop.png"));
301 expect(images(await text("/og/app/peek-app.svg"))).toBe(0);
302 });
303 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs