Social cards are cached by the page they show and a v as the site writes it, and the same card is drawn once
4 files+126−210/4 viewed
| 1 | 1 | import { OG_RENDER_VERSION } from "@g1t/contracts/og"; | |
| 2 | 2 | ||
| 3 | + | import { cardPath, clean } from "./resolve.ts"; | |
| 4 | + | ||
| 3 | 5 | /** | |
| 4 | 6 | * The version of the cards' design, part of every cache key. Bump it (in | |
| 5 | 7 | * `packages/contracts/src/og.ts`, which the site and the docs read too) | |
| ⋯ | |||
| 9 | 11 | export const RENDER_VERSION = OG_RENDER_VERSION; | |
| 10 | 12 | ||
| 11 | 13 | /** | |
| 14 | + | * A `v` as the site and the docs write it (`ogVersion` in | |
| 15 | + | * packages/contracts): the render version, then, for the site, a | |
| 16 | + | * fingerprint of what the card shows, at most seven base-36 characters. | |
| 17 | + | */ | |
| 18 | + | const VERSION = new RegExp(`^${RENDER_VERSION.replace(/\./g, "\\.")}(\\.[0-9a-z]{1,7})?$`); | |
| 19 | + | ||
| 20 | + | /** | |
| 12 | 21 | * The address a card is cached under: the render version, then only the | |
| 13 | − | * parameters that change the card, in order, including the page's own | |
| 14 | − | * content version `v`, so stray ones (trackers, cache busters) cannot fill | |
| 15 | − | * the cache. | |
| 22 | + | * parameters that change the card, in order, so stray ones (trackers, | |
| 23 | + | * cache busters) cannot fill the cache. A page's address is the page whose | |
| 24 | + | * card it shows (`cardPath`), the docs' text as the card draws it, and the | |
| 25 | + | * page's own content version `v` only in the form the site writes it. | |
| 16 | 26 | */ | |
| 17 | 27 | export function cacheKey(url: URL): string { | |
| 18 | − | const wanted = | |
| 19 | − | url.pathname === "/docs" ? ["title", "section", "description", "v"] : url.pathname === "/image" ? ["path", "v"] : []; | |
| 20 | 28 | const key = new URL(url.pathname, url.origin); | |
| 21 | 29 | key.searchParams.set("render", RENDER_VERSION); | |
| 22 | − | for (const name of wanted) { | |
| 23 | − | const value = url.searchParams.get(name); | |
| 30 | + | const set = (name: string, value: string | null) => { | |
| 24 | 31 | if (value !== null) key.searchParams.set(name, value); | |
| 32 | + | }; | |
| 33 | + | if (url.pathname === "/docs") { | |
| 34 | + | // As docsCard reads them. | |
| 35 | + | set("title", clean(url.searchParams.get("title"), 160)); | |
| 36 | + | set("section", clean(url.searchParams.get("section"), 60)); | |
| 37 | + | set("description", clean(url.searchParams.get("description"), 300)); | |
| 38 | + | } else if (url.pathname === "/image") { | |
| 39 | + | set("path", cardPath(url.searchParams.get("path") ?? "/")); | |
| 40 | + | } else { | |
| 41 | + | return key.toString(); | |
| 25 | 42 | } | |
| 43 | + | const version = url.searchParams.get("v"); | |
| 44 | + | if (version !== null && VERSION.test(version)) set("v", version); | |
| 45 | + | return key.toString(); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * The address a drawn card is also kept under, by what it shows: two | |
| 50 | + | * addresses for the same card (an old `v`, a made-up one) share one | |
| 51 | + | * drawing rather than each being drawn. | |
| 52 | + | */ | |
| 53 | + | export async function drawnKey(origin: string, card: unknown): Promise<string> { | |
| 54 | + | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(card))); | |
| 55 | + | const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); | |
| 56 | + | const key = new URL("/drawn", origin); | |
| 57 | + | key.searchParams.set("render", RENDER_VERSION); | |
| 58 | + | key.searchParams.set("card", hex); | |
| 26 | 59 | return key.toString(); | |
| 27 | 60 | } | |
| 31 | 31 | import mono400 from "./fonts/ibm-plex-mono-400.ttf"; | |
| 32 | 32 | import mono500 from "./fonts/ibm-plex-mono-500.ttf"; | |
| 33 | 33 | import { cardPng } from "./render.ts"; | |
| 34 | − | import { cacheKey } from "./cache.ts"; | |
| 34 | + | import { cacheKey, drawnKey } from "./cache.ts"; | |
| 35 | 35 | import { type Shot, screenshotOf, sweep, take } from "./capture.ts"; | |
| 36 | 36 | import { parseShot } from "./screenshot.ts"; | |
| 37 | − | import { BRAND, type Card, docsCard, resolve } from "./resolve.ts"; | |
| 37 | + | import { BRAND, type Card, cardPath, docsCard, resolve } from "./resolve.ts"; | |
| 38 | 38 | ||
| 39 | 39 | interface Env { | |
| 40 | 40 | /** Uploaded avatars by hash, with `{ contentType }`; written by identity. */ | |
| ⋯ | |||
| 122 | 122 | const hit = await cache.match(key); | |
| 123 | 123 | if (hit) return hit; | |
| 124 | 124 | ||
| 125 | − | const card = await cardFor(url, env); | |
| 126 | − | const failed = card.kind === "brand" && card.failed === true; | |
| 125 | + | const found = await lookUpCard(url, env); | |
| 126 | + | const failed = found.kind === "brand" && found.failed === true; | |
| 127 | 127 | // Every page without a card of its own shares the one brand card, so | |
| 128 | 128 | // made-up paths cannot make the service draw it again and again. | |
| 129 | 129 | const brandKey = cacheKey(new URL("/", url)); | |
| 130 | − | if (card.kind === "brand" && key !== brandKey) { | |
| 130 | + | if (found.kind === "brand" && key !== brandKey) { | |
| 131 | 131 | const brand = await cache.match(brandKey); | |
| 132 | 132 | if (brand) return withCacheControl(brand, failed); | |
| 133 | 133 | } | |
| 134 | + | // The same card under another address (another `v`): drawn once. | |
| 135 | + | const drawn = found.kind === "brand" ? null : await drawnKey(url.origin, found); | |
| 136 | + | const same = drawn ? await cache.match(drawn) : undefined; | |
| 137 | + | if (same) { | |
| 138 | + | ctx.waitUntil(cache.put(key, same.clone())); | |
| 139 | + | return same; | |
| 140 | + | } | |
| 141 | + | const card = await withIcon(found, env); | |
| 134 | 142 | ||
| 135 | 143 | let png: Uint8Array; | |
| 136 | 144 | let fellBack = false; | |
| ⋯ | |||
| 167 | 175 | ctx.waitUntil(cache.put(brandKey, response.clone())); | |
| 168 | 176 | } else { | |
| 169 | 177 | ctx.waitUntil(cache.put(key, response.clone())); | |
| 178 | + | if (drawn) ctx.waitUntil(cache.put(drawn, response.clone())); | |
| 170 | 179 | } | |
| 171 | 180 | return withCacheControl(response, failed); | |
| 172 | 181 | }, | |
| ⋯ | |||
| 187 | 196 | async function iconFor(env: Env, avatar: string | null | undefined): Promise<string | undefined> { | |
| 188 | 197 | if (!avatar || !/^[0-9a-f]{64}$/.test(avatar)) return undefined; | |
| 189 | 198 | try { | |
| 199 | + | // An avatar's key is its hash: what is under it never changes. | |
| 190 | 200 | const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(avatar, { | |
| 191 | 201 | type: "arrayBuffer", | |
| 202 | + | cacheTtl: 86_400, | |
| 192 | 203 | }); | |
| 193 | 204 | const type = metadata?.contentType; | |
| 194 | 205 | if (!value || !type || !DRAWABLE.has(type)) return undefined; | |
| ⋯ | |||
| 201 | 212 | } | |
| 202 | 213 | } | |
| 203 | 214 | ||
| 204 | − | async function cardFor(url: URL, env: Env): Promise<Card> { | |
| 205 | − | const card = await lookUpCard(url, env); | |
| 215 | + | async function withIcon(card: Card, env: Env): Promise<Card> { | |
| 206 | 216 | if (card.kind === "workspace" || card.kind === "person") return { ...card, icon: await iconFor(env, card.avatar) }; | |
| 207 | 217 | return card; | |
| 208 | 218 | } | |
| ⋯ | |||
| 210 | 220 | async function lookUpCard(url: URL, env: Env): Promise<Card> { | |
| 211 | 221 | if (url.pathname === "/docs") return docsCard(url.searchParams); | |
| 212 | 222 | if (url.pathname === "/") return BRAND; | |
| 213 | − | return resolve(url.searchParams.get("path") ?? "/", { | |
| 223 | + | // The page whose card it is, as the cache key has it (cache.ts). | |
| 224 | + | return resolve(cardPath(url.searchParams.get("path") ?? "/"), { | |
| 214 | 225 | identity: identityClient(env.IDENTITY), | |
| 215 | 226 | repos: reposClient(env.REPOS), | |
| 216 | 227 | work: workClient(env.WORK), | |
| 4 | 4 | import type { Issue, Project, Pull, Repo, Result, Workspace } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { RENDER_VERSION, cacheKey } from "./cache.ts"; | |
| 7 | − | import { type Sources, clean, docsCard, resolve, segments } from "./resolve.ts"; | |
| 7 | + | import { type Sources, cardPath, clean, docsCard, resolve, segments } from "./resolve.ts"; | |
| 8 | 8 | ||
| 9 | 9 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 10 | 10 | const notFound: Result<never> = { ok: false, error: { code: "not_found", message: "Not found" } }; | |
| ⋯ | |||
| 227 | 227 | }); | |
| 228 | 228 | ||
| 229 | 229 | test("the cache key keeps only what changes the card", () => { | |
| 230 | − | const a = cacheKey(new URL("https://og.g1t.sh/image?utm=1&path=/acme/web&v=3")); | |
| 231 | − | const b = cacheKey(new URL("https://og.g1t.sh/image?path=/acme/web&v=3&x=2")); | |
| 230 | + | const v = `${RENDER_VERSION}.1z141z3`; | |
| 231 | + | const a = cacheKey(new URL(`https://og.g1t.sh/image?utm=1&path=/acme/web&v=${v}`)); | |
| 232 | + | const b = cacheKey(new URL(`https://og.g1t.sh/image?path=/acme/web&v=${v}&x=2`)); | |
| 232 | 233 | assert.equal(a, b); | |
| 233 | − | assert.notEqual(a, cacheKey(new URL("https://og.g1t.sh/image?path=/acme/web&v=4"))); | |
| 234 | + | assert.notEqual(a, cacheKey(new URL(`https://og.g1t.sh/image?path=/acme/web&v=${RENDER_VERSION}.abc`))); | |
| 234 | 235 | assert.match(a, new RegExp(`render=${RENDER_VERSION}`)); | |
| 235 | 236 | // The keys cards were kept under before the render version was. | |
| 236 | 237 | assert.notEqual(a, "https://og.g1t.sh/image?design=1&path=%2Facme%2Fweb&v=3"); | |
| 237 | − | const docs = cacheKey(new URL("https://og.g1t.sh/docs?title=Quickstart&v=2&utm=x")); | |
| 238 | − | assert.equal(docs, `https://og.g1t.sh/docs?render=${RENDER_VERSION}&title=Quickstart&v=2`); | |
| 238 | + | const docs = cacheKey(new URL(`https://og.g1t.sh/docs?title=Quickstart&v=${RENDER_VERSION}&utm=x`)); | |
| 239 | + | assert.equal(docs, `https://og.g1t.sh/docs?render=${RENDER_VERSION}&title=Quickstart&v=${RENDER_VERSION}`); | |
| 240 | + | // The docs' text as the card draws it. | |
| 241 | + | assert.equal(cacheKey(new URL("https://og.g1t.sh/docs?title=%20Quick%0Astart%20")), cacheKey(new URL("https://og.g1t.sh/docs?title=Quick%20start"))); | |
| 242 | + | }); | |
| 243 | + | ||
| 244 | + | test("a v the site never writes is left out of the key", () => { | |
| 245 | + | const plain = cacheKey(new URL("https://og.g1t.sh/image?path=/acme/web")); | |
| 246 | + | for (const v of ["random", "999", `${RENDER_VERSION}.TOOLONG12`, `${RENDER_VERSION}.ab-c`, `x${RENDER_VERSION}`]) { | |
| 247 | + | assert.equal(cacheKey(new URL(`https://og.g1t.sh/image?path=/acme/web&v=${encodeURIComponent(v)}`)), plain, v); | |
| 248 | + | } | |
| 249 | + | }); | |
| 250 | + | ||
| 251 | + | test("a path is keyed by the page whose card it shows", () => { | |
| 252 | + | const key = (path: string) => cacheKey(new URL(`https://og.g1t.sh/image?path=${encodeURIComponent(path)}`)); | |
| 253 | + | assert.equal(key("/acme/web/tree/main/src?x=1"), key("/acme/web")); | |
| 254 | + | assert.equal(key("/acme/web/pull/3/files"), key("/acme/web/pull/3")); | |
| 255 | + | assert.notEqual(key("/acme/web/pull/3"), key("/acme/web")); | |
| 256 | + | assert.equal(key("/Acme/-/settings"), key("/acme")); | |
| 257 | + | assert.equal(key("/pricing/"), key("/pricing")); | |
| 258 | + | assert.equal(key("/not a name/web"), key("/")); | |
| 259 | + | assert.equal(key("/login/extra/parts"), key("/")); | |
| 260 | + | assert.equal(cardPath("/u/Ada"), "/u/ada"); | |
| 261 | + | assert.equal(cardPath("/acme/web/issues/12"), "/acme/web/issues/12"); | |
| 262 | + | assert.equal(cardPath("/acme/web/issues/012"), "/acme/web"); | |
| 263 | + | assert.equal(cardPath("/acme/web/soon/no-such-thing"), "/acme/web"); | |
| 264 | + | assert.equal(cardPath("//evil"), "/"); | |
| 265 | + | }); | |
| 266 | + | ||
| 267 | + | test("a path resolves to the same card as the page it is keyed by", async () => { | |
| 268 | + | const { sources: s } = sources(); | |
| 269 | + | for (const path of ["/acme/web/tree/main", "/acme/web/pull/7/files", "/acme/-/settings", "/acme/web/issues/3"]) { | |
| 270 | + | assert.deepEqual(await resolve(path, s), await resolve(cardPath(path), s), path); | |
| 271 | + | } | |
| 239 | 272 | }); | |
| 234 | 234 | } | |
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | + | /** | |
| 238 | + | * The page whose card `path` shows, asking no one: the parts of an address | |
| 239 | + | * a card does not read are dropped, and an address no card is for becomes | |
| 240 | + | * `/`, the brand card. `/acme/web/tree/main/src` and `/acme/web?tab=1` are | |
| 241 | + | * both `/acme/web`. The cache key and the lookup both use it, so made-up | |
| 242 | + | * addresses cannot make the service draw the same card again and again. | |
| 243 | + | */ | |
| 244 | + | export function cardPath(path: string): string { | |
| 245 | + | const parts = segments(path); | |
| 246 | + | if (!parts || parts.length === 0) return "/"; | |
| 247 | + | const join = (...kept: string[]) => `/${kept.map(encodeURIComponent).join("/")}`; | |
| 248 | + | const [owner, repo, section, item] = parts; | |
| 249 | + | const lower = owner.toLowerCase(); | |
| 250 | + | if (lower === "u") return repo !== undefined && parts.length === 2 && NAME.test(repo) ? join("u", repo.toLowerCase()) : "/"; | |
| 251 | + | if (RESERVED.has(lower)) { | |
| 252 | + | if (parts.length === 1) return lower in PAGES ? join(lower) : "/"; | |
| 253 | + | const page = `${lower}/${parts[1].toLowerCase()}`; | |
| 254 | + | return parts.length === 2 && page in PAGES ? join(lower, parts[1].toLowerCase()) : "/"; | |
| 255 | + | } | |
| 256 | + | if (!NAME.test(owner)) return "/"; | |
| 257 | + | if (repo === undefined || repo === "-") return join(lower); | |
| 258 | + | if (!NAME.test(repo)) return "/"; | |
| 259 | + | if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) return join(owner, repo, "issues", item); | |
| 260 | + | if (section === "pull" && item !== undefined && isNumber(item)) return join(owner, repo, "pull", item); | |
| 261 | + | if (section === "soon" && item !== undefined && parts.length === 4 && roadmapItem(item)) return join(owner, repo, "soon", item); | |
| 262 | + | return join(owner, repo); | |
| 263 | + | } | |
| 264 | + | ||
| 237 | 265 | /** The card for a page of g1t.sh, as an anonymous visitor would see it. */ | |
| 238 | 266 | export async function resolve(path: string, sources: Sources): Promise<Card> { | |
| 239 | 267 | const parts = segments(path); |