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

A test proves every top-level page and a repository, issue, pull and person page carry og and twitter tags #2452

Merged opened this

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

Merged

scout/forge:scout/2446-a-test-proves-every-top-level-page-and-a → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/2446-a-test-proves-every-top-level-page-and-a was merged into main as d209671.

Review

Sign in to review this change.
8 files changed +190 −21
src/http/server.ts +2
@@ -1172,6 +1172,8 @@ export function buildRouter(): Router<Handler> {
11721172 router.add("GET", "/og/run/:ref.png", ogCard("run", "png"));
11731173 router.add("GET", "/og/map/:ref.png", ogCard("map", "png"));
11741174 router.add("GET", "/og/audit.png", ogCard("audit", "png"));
1175 router.add("GET", "/og/site.svg", ogCard("site"));
1176 router.add("GET", "/og/site.png", ogCard("site", "png"));
11751177 // The pictures of an environment, read like the repository.
11761178 router.add("GET", "/:owner/:name/looks/:env/:commit/:kind.png", lookImage);
11771179
src/web/layout.ts +13 −17
@@ -65,9 +65,9 @@ export interface PageOptions {
6565 /**
6666 * This page's link-preview card, a same-origin path such as
6767 * `/og/audit.png` (`pages/og.ts`). Only for a page whose card answers a
68 * signed-out unfurler; absent, the forge's icon stands in.
68 * signed-out unfurler; absent, the forge's own card (`/og/site.png`) stands in.
6969 */
70 ogImage?: string;
70 ogImage?: string | undefined;
7171 /** This page's feed, advertised in the head so a reader can find it. */
7272 feed?: { href: string; title: string };
7373 /** Breadcrumb segments rendered in the topbar, e.g. owner / repo. */
@@ -605,21 +605,17 @@ export function page(options: PageOptions, status = 200): Response {
605605 />`
606606 : null
607607 }
608 ${
609 options.ogImage !== undefined && options.ogImage.startsWith("/og/")
610 ? html`<meta
611 property="og:image"
612 content="${ctx.url.origin}${options.ogImage}"
613 />
614 <meta property="og:image:width" content="1200" />
615 <meta property="og:image:height" content="630" />
616 <meta name="twitter:card" content="summary_large_image" />`
617 : html`<meta
618 property="og:image"
619 content="${ctx.url.origin}/icons/forge-512.png"
620 />
621 <meta name="twitter:card" content="summary" />`
622 }
608 <meta
609 property="og:image"
610 content="${ctx.url.origin}${
611 options.ogImage !== undefined && options.ogImage.startsWith("/og/")
612 ? options.ogImage
613 : "/og/site.png"
614 }"
615 />
616 <meta property="og:image:width" content="1200" />
617 <meta property="og:image:height" content="630" />
618 <meta name="twitter:card" content="summary_large_image" />
623619 <link rel="manifest" href="/manifest.webmanifest" />
624620 ${links.map(
625621 (l) =>
src/web/og/path.ts added +21
@@ -0,0 +1,21 @@
1 /**
2 * Where a page's own social card lives: `/og/<kind>/owner~name~number.png`.
3 * Only a thread of a public repository, not sealed, has one an unfurler can
4 * fetch; any other thread gets nothing here and the layout's site card stands in.
5 */
6
7 interface Where {
8 ownerName: string;
9 name: string;
10 visibility: string;
11 }
12
13 export function cardPath(
14 kind: "issue" | "pull",
15 repo: Where,
16 thread: { number: number; sealed: boolean },
17 ): string | undefined {
18 // A sealed thread's card is a 404 to anyone signed out, as is a private repo's thread.
outdated

Only visibility is checked here, so the sealed check is left to each caller, and change.ts doesn't do it. Pass sealed in and decide it here, then test both the private and the sealed fallback.

19 if (repo.visibility !== "public" || thread.sealed) return undefined;
outdated

This matches publicRepo in og.ts except in one case: a public repo whose owner is not in standing, where canRead(db, repo, undefined) is false. There the page names a card that 404s. That is harmless (the page is already admin-only), but routing both through one predicate would keep them from drifting.

Non-blocking. This guard copies part of what threadInput checks, through mayView and canRead, but not all of it. In particular it does not check ownerInStanding. A public repository whose owner is suspended would still name a card that answers 404 anonymously. Only an admin can see that page, so it does not matter now. If the two checks drift further apart, the cleaner fix is to have cardPath ask mayView(db, repo, thread, undefined) directly, so the page and the card route answer the same question.

20 return `/og/${kind}/${repo.ownerName}~${repo.name}~${thread.number}.png`;
21 }
src/web/pages/og.ts +10 −2
@@ -1,6 +1,6 @@
11 /**
22 * The social card on the web: GET /og/{repo,app,issue,pull,run,map}/:ref.svg
3 * and GET /og/audit.svg.
3 * and GET /og/audit.svg and /og/site.svg (the card of every page without its own).
44 * Public by design — it is what a link preview fetches without a session —
55 * so it says only what an anonymous reader may already see: a public repo
66 * (`owner~name`; any other repo, private or not, is a title and the word
@@ -147,7 +147,14 @@ function waiting(ctx: Ctx, repo: Repo, issue: Issue): string {
147147 return `waits on ${shown.join(", ")}${more > 0 ? ` +${more}` : ""}`;
148148 }
149149
150 export type OgKind = "repo" | "app" | "issue" | "pull" | "run" | "map" | "audit";
150 export type OgKind = "repo" | "app" | "issue" | "pull" | "run" | "map" | "audit" | "site";
151
152 /** The card for any page without a card of its own: it says only what the forge is. */
153 const SITE_CARD: CardInput = {
154 kind: "page",
155 title: "forge",
156 lines: ["Repositories, issues, pulls and the crew that works them."],
157 };
151158
152159 /** The audit page's own bound. */
153160 const AUDIT_LIMIT = 200;
@@ -345,6 +352,7 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
345352 if (kind === "run") return runInput(ctx, ref);
346353 if (kind === "map") return mapCardInput(ctx, ref);
347354 if (kind === "audit") return ref === "" ? auditCardInput(ctx) : undefined;
355 if (kind === "site") return ref === "" ? SITE_CARD : undefined;
348356 if (kind === "repo") return repoCard(ctx, ref);
349357 if (!isSlug(ref)) return undefined;
350358 const record = getAppBySlug(db, ref);
src/web/studio/change.ts +2
@@ -9,6 +9,7 @@
99 * bigger.
1010 */
1111
12 import { cardPath } from "../og/path.ts";
1213 import { pullProposalOf } from "../../core/proposal_token.ts";
1314 import { getIssueById } from "../../core/issues.ts";
1415 import { mayEditIssue } from "../../actions/issues.ts";
@@ -622,6 +623,7 @@ async function changeConversation(
622623 return page({
623624 ctx,
624625 title: `${pull.title} · ${repo.name}`,
626 ogImage: cardPath("pull", repo, pull),
outdated

A sealed pull, the one that fixes a sealed issue, gets a card path here that /og/pull/… answers with 404 (threadInput → mayView). Guard on pull.sealed as tell.ts does, or put the check in cardPath.

625627 description: threadLine({
626628 private: repo.visibility === "private",
627629 word,
src/web/studio/tell.ts +2
@@ -8,6 +8,7 @@
88 * one closed Details section.
99 */
1010
11 import { cardPath } from "../og/path.ts";
1112 import { mayEditIssue } from "../../actions/issues.ts";
1213 import { marksByName } from "../../core/marks.ts";
1314 import { threadNote } from "../../crew/direct.ts";
@@ -420,6 +421,7 @@ async function studioTell(
420421 return page({
421422 ctx,
422423 title: `${issue.title} · ${repo.name}`,
424 ogImage: cardPath("issue", repo, issue),
423425 description: threadLine({
424426 private: repo.visibility === "private" || issue.sealed,
425427 word: wordInfo?.word ?? (issue.state === "open" ? "open" : "closed"),
test/og-designs.test.ts +2 −2
@@ -347,7 +347,7 @@ describe("over HTTP", () => {
347347 const audit = await (await get("/audit")).text();
348348 expect(audit).toContain(`content="${base}/og/audit.png"`);
349349 const home = await (await get("/")).text();
350 expect(home).toContain("/icons/forge-512.png");
351 expect(home).not.toContain("summary_large_image");
350 expect(home).toContain(`content="${base}/og/site.png"`);
351 expect(home).toContain(`content="summary_large_image"`);
352352 });
353353 });
test/og-tags.test.ts added +138
@@ -0,0 +1,138 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp as createForge, type App } from "../src/app.ts";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { getRepo } from "../src/core/repos.ts";
8 import { createIssue } from "../src/core/issues.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12
13 /** The head tags a link preview reads, by `property`/`name`. */
14 function tags(html: string): Map<string, string> {
15 const out = new Map<string, string>();
16 for (const m of html.matchAll(/<meta\s+(?:property|name)="((?:og|twitter):[^"]+)"\s+content="([^"]*)"/g))
17 if (!out.has(m[1]!)) out.set(m[1]!, m[2]!);
18 return out;
19 }
20
21 describe("every kind of page unfurls", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 let base: string;
26 let cookie = "";
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-ogtags-"));
30 app = createForge({ dataDir, port: 0 });
31 servers = await serve(app);
32 base = servers.url;
33 const u = await createUser(app.db, {
34 trusted: true,
35 username: "alice",
36 email: "[email protected]",
37 password: "alice-passphrase",
38 adminIfFirst: true,
39 });
40 if (!u.ok) throw new Error(u.error);
41 cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
42 const hidden = await createRepo(app, u.value, { name: "hidden", visibility: "private" });
43 if (!hidden.ok) throw new Error(String(hidden.error));
44 const secret = createIssue(app.db, {
45 repoId: getRepo(app.db, "alice", "hidden")!.id,
46 authorId: u.value.id,
47 title: "A private issue",
48 kind: "issue",
49 });
50 if (!secret.ok) throw new Error(String(secret.error));
51 const made = await createRepo(app, u.value, { name: "shown", visibility: "public" });
52 if (!made.ok) throw new Error(String(made.error));
53 const repoId = getRepo(app.db, "alice", "shown")!.id;
54 for (const [kind, sealed] of [
55 ["issue", false],
56 ["pull", false],
57 ["issue", true],
58 ["pull", true],
59 ] as const) {
60 const i = createIssue(app.db, { repoId, authorId: u.value.id, title: `A ${kind}`, kind, sealed });
61 if (!i.ok) throw new Error(String(i.error));
62 if (kind === "pull")
63 app.db
64 .query(
65 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
66 VALUES (?1, 'main', 'topic', '', '', ?2)`,
67 )
68 .run(i.value.id, repoId);
69 }
70 });
71
72 afterAll(async () => {
73 await servers.stop();
74 await rm(dataDir, { recursive: true, force: true });
75 });
76
77 // Signed out, which is how an unfurler arrives. Pages that send a visitor
78 // to sign in (/map, /new, /board…) answer with a redirect, not a page.
79 const pages = [
80 "/",
81 "/explore",
82 "/signin",
83 "/signup",
84 "/audit",
85 "/apps",
86 "/contribute",
87 "/search",
88 "/family",
89 "/recover",
90 "/front",
91 "/alice",
92 "/alice/shown",
93 "/alice/shown/issues/1",
94 "/alice/shown/pulls/2",
95 ];
96
97 // A page about one thing shows that thing's card, not the forge's.
98 const own: Record<string, string> = {
99 "/audit": "/og/audit.png",
100 "/alice/shown": "/og/repo/alice~shown.png",
101 "/alice/shown/issues/1": "/og/issue/alice~shown~1.png",
102 "/alice/shown/pulls/2": "/og/pull/alice~shown~2.png",
103 };
104
105 for (const path of pages) {
106 test(`${path} carries og and twitter tags with a card that answers`, async () => {
107 const res = await fetch(base + path, { redirect: "manual" });
108 expect(res.status).toBe(200);
109 const t = tags(await res.text());
110 expect(t.get("og:title")).toBeTruthy();
111 expect(t.get("og:description")).toBeTruthy();
112 expect(t.get("twitter:card")).toBe("summary_large_image");
113 const image = t.get("og:image")!;
114 expect(image).toBeTruthy();
115 expect(new URL(image).pathname).toBe(own[path] ?? "/og/site.png");
116 const card = await fetch(base + new URL(image).pathname);
117 expect(card.status).toBe(200);
118 expect(card.headers.get("content-type")).toBe("image/png");
119 await card.arrayBuffer();
120 });
121 }
122
123 // A card an unfurler cannot fetch is never named: the site card stands in,
124 // even when the page is viewed by a signed-in writer who can see the thing.
125 for (const path of ["/alice/hidden/issues/1", "/alice/shown/issues/3", "/alice/shown/pulls/4"]) {
126 test(`${path} falls back to the site card`, async () => {
127 const res = await fetch(base + path, { headers: { cookie } });
128 expect(res.status).toBe(200);
129 const t = tags(await res.text());
130 expect(new URL(t.get("og:image")!).pathname).toBe("/og/site.png");
131 expect(t.get("twitter:card")).toBe("summary_large_image");
132 const card = await fetch(base + "/og/site.png");
133 expect(card.status).toBe(200);
134 expect(card.headers.get("content-type")).toBe("image/png");
135 await card.arrayBuffer();
136 });
137 }
138 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs