Skip to content

Commit

Social cards are cached by the page they show and a v as the site writes it, and the same card is drawn once

syntaqxcommitted Parent55fc326Browse files
4 files+126−210/4 viewed
+40−7
11 import { OG_RENDER_VERSION } from "@g1t/contracts/og";
22
3+import { cardPath, clean } from "./resolve.ts";
4+
35 /**
46 * The version of the cards' design, part of every cache key. Bump it (in
57 * `packages/contracts/src/og.ts`, which the site and the docs read too)
911 export const RENDER_VERSION = OG_RENDER_VERSION;
1012
1113 /**
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+/**
1221 * 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.
1626 */
1727 export function cacheKey(url: URL): string {
18− const wanted =
19− url.pathname === "/docs" ? ["title", "section", "description", "v"] : url.pathname === "/image" ? ["path", "v"] : [];
2028 const key = new URL(url.pathname, url.origin);
2129 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) => {
2431 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();
2542 }
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);
2659 return key.toString();
2760 }
+19−8
3131 import mono400 from "./fonts/ibm-plex-mono-400.ttf";
3232 import mono500 from "./fonts/ibm-plex-mono-500.ttf";
3333 import { cardPng } from "./render.ts";
34−import { cacheKey } from "./cache.ts";
34+import { cacheKey, drawnKey } from "./cache.ts";
3535 import { type Shot, screenshotOf, sweep, take } from "./capture.ts";
3636 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";
3838
3939 interface Env {
4040 /** Uploaded avatars by hash, with `{ contentType }`; written by identity. */
122122 const hit = await cache.match(key);
123123 if (hit) return hit;
124124
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;
127127 // Every page without a card of its own shares the one brand card, so
128128 // made-up paths cannot make the service draw it again and again.
129129 const brandKey = cacheKey(new URL("/", url));
130− if (card.kind === "brand" && key !== brandKey) {
130+ if (found.kind === "brand" && key !== brandKey) {
131131 const brand = await cache.match(brandKey);
132132 if (brand) return withCacheControl(brand, failed);
133133 }
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);
134142
135143 let png: Uint8Array;
136144 let fellBack = false;
167175 ctx.waitUntil(cache.put(brandKey, response.clone()));
168176 } else {
169177 ctx.waitUntil(cache.put(key, response.clone()));
178+ if (drawn) ctx.waitUntil(cache.put(drawn, response.clone()));
170179 }
171180 return withCacheControl(response, failed);
172181 },
187196 async function iconFor(env: Env, avatar: string | null | undefined): Promise<string | undefined> {
188197 if (!avatar || !/^[0-9a-f]{64}$/.test(avatar)) return undefined;
189198 try {
199+ // An avatar's key is its hash: what is under it never changes.
190200 const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(avatar, {
191201 type: "arrayBuffer",
202+ cacheTtl: 86_400,
192203 });
193204 const type = metadata?.contentType;
194205 if (!value || !type || !DRAWABLE.has(type)) return undefined;
201212 }
202213 }
203214
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> {
206216 if (card.kind === "workspace" || card.kind === "person") return { ...card, icon: await iconFor(env, card.avatar) };
207217 return card;
208218 }
210220 async function lookUpCard(url: URL, env: Env): Promise<Card> {
211221 if (url.pathname === "/docs") return docsCard(url.searchParams);
212222 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") ?? "/"), {
214225 identity: identityClient(env.IDENTITY),
215226 repos: reposClient(env.REPOS),
216227 work: workClient(env.WORK),
+39−6
44 import type { Issue, Project, Pull, Repo, Result, Workspace } from "@g1t/contracts";
55
66 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";
88
99 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
1010 const notFound: Result<never> = { ok: false, error: { code: "not_found", message: "Not found" } };
227227 });
228228
229229 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`));
232233 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`)));
234235 assert.match(a, new RegExp(`render=${RENDER_VERSION}`));
235236 // The keys cards were kept under before the render version was.
236237 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+ }
239272 });
+28−0
234234 }
235235 }
236236
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+
237265 /** The card for a page of g1t.sh, as an anonymous visitor would see it. */
238266 export async function resolve(path: string, sources: Sources): Promise<Card> {
239267 const parts = segments(path);