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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2446-a-test-proves-every-top-level-page-and-a → main 3 commits fork 3 threads · 3 unresolved
src/http/server.ts +2
| @@ -1172,6 +1172,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1172 | 1172 | router.add("GET", "/og/run/:ref.png", ogCard("run", "png")); |
| 1173 | 1173 | router.add("GET", "/og/map/:ref.png", ogCard("map", "png")); |
| 1174 | 1174 | 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")); | |
| 1175 | 1177 | // The pictures of an environment, read like the repository. |
| 1176 | 1178 | router.add("GET", "/:owner/:name/looks/:env/:commit/:kind.png", lookImage); |
| 1177 | 1179 |
src/web/layout.ts +13 −17
| @@ -65,9 +65,9 @@ export interface PageOptions { | ||
| 65 | 65 | /** |
| 66 | 66 | * This page's link-preview card, a same-origin path such as |
| 67 | 67 | * `/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. | |
| 69 | 69 | */ |
| 70 | ogImage?: string; | |
| 70 | ogImage?: string | undefined; | |
| 71 | 71 | /** This page's feed, advertised in the head so a reader can find it. */ |
| 72 | 72 | feed?: { href: string; title: string }; |
| 73 | 73 | /** Breadcrumb segments rendered in the topbar, e.g. owner / repo. */ |
| @@ -605,21 +605,17 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 605 | 605 | />` |
| 606 | 606 | : null |
| 607 | 607 | } |
| 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" /> | |
| 623 | 619 | <link rel="manifest" href="/manifest.webmanifest" /> |
| 624 | 620 | ${links.map( |
| 625 | 621 | (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. | |
| 19 | if (repo.visibility !== "public" || thread.sealed) return undefined; | |
outdated
This matches Non-blocking. This guard copies part of what |
||
| 20 | return `/og/${kind}/${repo.ownerName}~${repo.name}~${thread.number}.png`; | |
| 21 | } |
src/web/pages/og.ts +10 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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). | |
| 4 | 4 | * Public by design — it is what a link preview fetches without a session — |
| 5 | 5 | * so it says only what an anonymous reader may already see: a public repo |
| 6 | 6 | * (`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 { | ||
| 147 | 147 | return `waits on ${shown.join(", ")}${more > 0 ? ` +${more}` : ""}`; |
| 148 | 148 | } |
| 149 | 149 | |
| 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 | }; | |
| 151 | 158 | |
| 152 | 159 | /** The audit page's own bound. */ |
| 153 | 160 | const AUDIT_LIMIT = 200; |
| @@ -345,6 +352,7 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | | ||
| 345 | 352 | if (kind === "run") return runInput(ctx, ref); |
| 346 | 353 | if (kind === "map") return mapCardInput(ctx, ref); |
| 347 | 354 | if (kind === "audit") return ref === "" ? auditCardInput(ctx) : undefined; |
| 355 | if (kind === "site") return ref === "" ? SITE_CARD : undefined; | |
| 348 | 356 | if (kind === "repo") return repoCard(ctx, ref); |
| 349 | 357 | if (!isSlug(ref)) return undefined; |
| 350 | 358 | const record = getAppBySlug(db, ref); |
src/web/studio/change.ts +2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * bigger. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { cardPath } from "../og/path.ts"; | |
| 12 | 13 | import { pullProposalOf } from "../../core/proposal_token.ts"; |
| 13 | 14 | import { getIssueById } from "../../core/issues.ts"; |
| 14 | 15 | import { mayEditIssue } from "../../actions/issues.ts"; |
| @@ -622,6 +623,7 @@ async function changeConversation( | ||
| 622 | 623 | return page({ |
| 623 | 624 | ctx, |
| 624 | 625 | 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 |
||
| 625 | 627 | description: threadLine({ |
| 626 | 628 | private: repo.visibility === "private", |
| 627 | 629 | word, |
src/web/studio/tell.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * one closed Details section. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { cardPath } from "../og/path.ts"; | |
| 11 | 12 | import { mayEditIssue } from "../../actions/issues.ts"; |
| 12 | 13 | import { marksByName } from "../../core/marks.ts"; |
| 13 | 14 | import { threadNote } from "../../crew/direct.ts"; |
| @@ -420,6 +421,7 @@ async function studioTell( | ||
| 420 | 421 | return page({ |
| 421 | 422 | ctx, |
| 422 | 423 | title: `${issue.title} · ${repo.name}`, |
| 424 | ogImage: cardPath("issue", repo, issue), | |
| 423 | 425 | description: threadLine({ |
| 424 | 426 | private: repo.visibility === "private" || issue.sealed, |
| 425 | 427 | word: wordInfo?.word ?? (issue.state === "open" ? "open" : "closed"), |
test/og-designs.test.ts +2 −2
| @@ -347,7 +347,7 @@ describe("over HTTP", () => { | ||
| 347 | 347 | const audit = await (await get("/audit")).text(); |
| 348 | 348 | expect(audit).toContain(`content="${base}/og/audit.png"`); |
| 349 | 349 | 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"`); | |
| 352 | 352 | }); |
| 353 | 353 | }); |
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 | }); |
Only visibility is checked here, so the sealed check is left to each caller, and
change.tsdoesn't do it. Passsealedin and decide it here, then test both the private and the sealed fallback.