Skip to content
305 linesCodeBlameRaw
1/**
2 * Social cards: the PNG a link to g1t shows in a chat, a post or a search
3 * result, at `https://og.g1t.sh`.
4 *
5 * GET /image?path=/<any page of g1t.sh>[&v=<version>]
6 * GET /docs?title=&section=&description=[&v=<version>]
7 *
8 * A page's card is looked up as an anonymous visitor would see the page,
9 * so nothing private ever reaches one (see `resolve.ts`). Cards are kept in
10 * the edge cache under the render version and the parameters that change
11 * them (see `cache.ts`). The site and the docs add `v`, which changes when
12 * the design does (`OG_RENDER_VERSION` in packages/contracts) or when what
13 * a card shows does, so a new logo or an edited title gets a new address.
14 *
15 * Also the `Screenshots` entrypoint, reached only through service
16 * bindings: a screenshot of each project's production, per deploy on
17 * g1t.page, and of a production deployed elsewhere at its address (see
18 * `capture.ts`).
19 */
20import { WorkerEntrypoint } from "cloudflare:workers";
21import { type ServiceBinding, identityClient, platformPaused, projectsClient, reposClient, workClient } from "@g1t/contracts";
22import { type RateLimitBinding, clientAddress, isLimited } from "@g1t/contracts/rate-limits";
23import type { Font } from "satori/standalone";
24import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
25import yogaWasm from "satori/yoga.wasm";
26
27import display500 from "./fonts/bricolage-grotesque-500.ttf";
28import display600 from "./fonts/bricolage-grotesque-600.ttf";
29import sans400 from "./fonts/hanken-grotesk-400.ttf";
30import sans500 from "./fonts/hanken-grotesk-500.ttf";
31import sans600 from "./fonts/hanken-grotesk-600.ttf";
32import sans700 from "./fonts/hanken-grotesk-700.ttf";
33import mono400 from "./fonts/ibm-plex-mono-400.ttf";
34import mono500 from "./fonts/ibm-plex-mono-500.ttf";
35import { cardPng } from "./render.ts";
36import { cacheKey, drawnKey } from "./cache.ts";
37import { type Shot, externalOf, refreshExternal, screenshotOf, sweep, take, takeExternal } from "./capture.ts";
38import { pausedCard } from "./paused.ts";
39import { parseExternalShot, parseShot } from "./screenshot.ts";
40import { BRAND, type Card, cardPath, docsCard, resolve } from "./resolve.ts";
41
42interface Env {
43 /** Uploaded avatars by hash, with `{ contentType }`; written by identity. */
44 AVATARS: KVNamespace;
45 IDENTITY: ServiceBinding;
46 REPOS: ServiceBinding;
47 WORK: ServiceBinding;
48 PROJECTS: ServiceBinding;
49 /** Whether rendering is paused across g1t (billing's `platform_pause`). */
50 BILLING?: ServiceBinding;
51 /** Browser Rendering, for production screenshots. */
52 BROWSER: Fetcher;
53 /** Production screenshots, by app hostname; a production deployed elsewhere's under `external/`. */
54 SCREENSHOTS: R2Bucket;
55 /** Cards drawn per address (RATE_LIMITS in packages/contracts). Absent: no limit. */
56 OG_RENDER_LIMIT?: RateLimitBinding;
57}
58
59/**
60 * Production screenshots. `capture` is called by deployments when
61 * production goes live; `image` by the site, for a project's overview, which
62 * decides who may see it.
63 */
64export class Screenshots extends WorkerEntrypoint<Env> {
65 /** Takes the screenshot of `{ host, commit }` in the background. */
66 async capture(input: unknown): Promise<boolean> {
67 const request = parseShot(input);
68 if (!request) return false;
69 this.ctx.waitUntil(take(this.env, request));
70 return true;
71 }
72
73 /**
74 * The screenshot of `{ host, commit }`, taken now if it has not been, or
75 * the last one kept for that app. Its `commit` says which it is.
76 */
77 async image(input: unknown): Promise<Shot | null> {
78 const request = parseShot(input);
79 if (!request) return null;
80 return screenshotOf(this.env, request);
81 }
82
83 /**
84 * Takes the screenshot of a production deployed elsewhere,
85 * `{ url, commit }` (`commit` a deploy's commit or a UTC day), in the
86 * background. Called by projects when the address is set or changed and
87 * when a production deployment is reported. False for an address that is
88 * not public.
89 */
90 async captureExternal(input: unknown): Promise<boolean> {
91 const request = parseExternalShot(input);
92 if (!request) return false;
93 this.ctx.waitUntil(takeExternal(this.env, request));
94 return true;
95 }
96
97 /**
98 * The kept screenshot of a production deployed elsewhere, `{ url,
99 * commit, since? }`, taken now when there is none. One more than a day
100 * old, or older than `since`, is returned as it is and taken again in
101 * the background.
102 */
103 async externalImage(input: unknown): Promise<Shot | null> {
104 const request = parseExternalShot(input);
105 if (!request) return null;
106 const { shot, stale } = await externalOf(this.env, request);
107 if (stale) this.ctx.waitUntil(takeExternal(this.env, request));
108 return shot;
109 }
110}
111
112/*
113 * g1t's typefaces, as on the site (packages/theme), but static: the
114 * renderer takes no variable fonts. Bricolage is cut at the optical size
115 * of the headlines it sets on a card.
116 */
117const FONTS: Font[] = [
118 { name: "Hanken Grotesk", data: sans400, weight: 400, style: "normal" },
119 { name: "Hanken Grotesk", data: sans500, weight: 500, style: "normal" },
120 { name: "Hanken Grotesk", data: sans600, weight: 600, style: "normal" },
121 { name: "Hanken Grotesk", data: sans700, weight: 700, style: "normal" },
122 { name: "Bricolage Grotesque", data: display500, weight: 500, style: "normal" },
123 { name: "Bricolage Grotesque", data: display600, weight: 600, style: "normal" },
124 { name: "IBM Plex Mono", data: mono400, weight: 400, style: "normal" },
125 { name: "IBM Plex Mono", data: mono500, weight: 500, style: "normal" },
126];
127
128const ASSETS = { yoga: yogaWasm, resvg: resvgWasm, fonts: FONTS };
129
130/** Kept an hour by browsers and a day at the edge. */
131const CACHE_CONTROL = "public, max-age=3600, s-maxage=86400";
132/** When a service could not be reached: soon tried again. */
133const BRIEF_CACHE_CONTROL = "public, max-age=60";
134
135/** When even the brand card cannot be drawn: never kept, so the next request tries again. */
136const NO_STORE = "no-store";
137
138export default {
139 /**
140 * Once a day: old screenshots out (capture.ts `sweep`), and productions
141 * deployed elsewhere taken again when theirs is a day old
142 * (`refreshExternal`).
143 */
144 async scheduled(_event: ScheduledController, env: Env): Promise<void> {
145 const removed = await sweep(env).catch((error) => (console.error("og: sweep failed", error), 0));
146 if (removed) console.log(`og: removed ${removed} old screenshots`);
147 const urls = await projectsClient(env.PROJECTS)
148 .externalProductions()
149 .catch((error) => (console.error("og: could not list productions deployed elsewhere", error), [] as string[]));
150 const taken = await refreshExternal(env, urls).catch((error) => (console.error("og: refresh failed", error), 0));
151 if (taken) console.log(`og: took ${taken} screenshots of productions deployed elsewhere`);
152 },
153
154 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
155 const url = new URL(request.url);
156 if (request.method !== "GET" && request.method !== "HEAD") {
157 return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } });
158 }
159 if (url.pathname === "/health") return new Response("ok");
160 if (url.pathname !== "/" && url.pathname !== "/image" && url.pathname !== "/docs") {
161 return new Response("Not found", { status: 404 });
162 }
163
164 const key = cacheKey(url);
165 const cache = (caches as unknown as { default: Cache }).default;
166 const hit = await cache.match(key);
167 if (hit) return hit;
168 // Rendering paused across g1t: no card is drawn (paused.ts).
169 if (await platformPaused(env.BILLING, "renders")) return pausedCard(await cache.match(cacheKey(new URL("/", url))));
170
171 // Every page without a card of its own shares the one brand card, so
172 // made-up paths cannot make the service draw it again and again.
173 const brandKey = cacheKey(new URL("/", url));
174 // A miss looks the page up and draws its card: limited per address.
175 // Past the limit, the brand card, kept only briefly so the page's own
176 // card is asked for again later.
177 if (key !== brandKey && (await isLimited(env.OG_RENDER_LIMIT, `ip:${clientAddress(request)}`))) {
178 const brand = await cache.match(brandKey);
179 if (brand) return withCacheControl(brand, true);
180 const drawn = await brandCard(ctx, cache, brandKey);
181 return drawn.ok ? withCacheControl(drawn, true) : drawn;
182 }
183
184 const found = await lookUpCard(url, env);
185 const failed = found.kind === "brand" && found.failed === true;
186 if (found.kind === "brand" && key !== brandKey) {
187 const brand = await cache.match(brandKey);
188 if (brand) return withCacheControl(brand, failed);
189 }
190 // The same card under another address (another `v`): drawn once.
191 const drawn = found.kind === "brand" ? null : await drawnKey(url.origin, found);
192 const same = drawn ? await cache.match(drawn) : undefined;
193 if (same) {
194 ctx.waitUntil(cache.put(key, same.clone()));
195 return same;
196 }
197 const card = await withIcon(found, env);
198
199 let png: Uint8Array;
200 let fellBack = false;
201 try {
202 png = await cardPng(card, ASSETS);
203 } catch (error) {
204 console.error("og: rendering failed", url.pathname, error);
205 try {
206 // An icon it could not draw is left out rather than losing the card;
207 // anything else gets the brand card, kept only briefly.
208 if ((card.kind === "workspace" || card.kind === "person") && card.icon) {
209 png = await cardPng({ ...card, icon: undefined }, ASSETS);
210 } else if (card.kind !== "brand") {
211 png = await cardPng(BRAND, ASSETS);
212 fellBack = true;
213 } else {
214 throw error;
215 }
216 } catch (again) {
217 console.error("og: the brand card could not be drawn either", again);
218 return new Response("The card could not be drawn", { status: 503, headers: { "cache-control": NO_STORE } });
219 }
220 }
221 const response = new Response(png, {
222 headers: {
223 "content-type": "image/png",
224 "cache-control": CACHE_CONTROL,
225 "access-control-allow-origin": "*",
226 "x-content-type-options": "nosniff",
227 },
228 });
229 if (fellBack) return withCacheControl(response, true);
230 if (card.kind === "brand") {
231 ctx.waitUntil(cache.put(brandKey, response.clone()));
232 } else {
233 ctx.waitUntil(cache.put(key, response.clone()));
234 if (drawn) ctx.waitUntil(cache.put(drawn, response.clone()));
235 }
236 return withCacheControl(response, failed);
237 },
238} satisfies ExportedHandler<Env>;
239
240/** A card shown because a service failed is kept only briefly, by anyone. */
241function withCacheControl(response: Response, failed: boolean): Response {
242 if (!failed) return response;
243 const brief = new Response(response.body, response);
244 brief.headers.set("cache-control", BRIEF_CACHE_CONTROL);
245 return brief;
246}
247
248/** The brand card when the cache has lost it: drawn once and kept again. */
249async function brandCard(ctx: ExecutionContext, cache: Cache, brandKey: string): Promise<Response> {
250 try {
251 const response = new Response(await cardPng(BRAND, ASSETS), {
252 headers: {
253 "content-type": "image/png",
254 "cache-control": CACHE_CONTROL,
255 "access-control-allow-origin": "*",
256 "x-content-type-options": "nosniff",
257 },
258 });
259 ctx.waitUntil(cache.put(brandKey, response.clone()));
260 return response;
261 } catch (error) {
262 console.error("og: the brand card could not be drawn", error);
263 return new Response("The card could not be drawn", { status: 503, headers: { "cache-control": NO_STORE } });
264 }
265}
266
267/** What satori can draw: PNG and JPEG. A WebP or GIF icon is left off the card. */
268const DRAWABLE = new Set(["image/png", "image/jpeg"]);
269
270/** A workspace's or person's uploaded icon as a data URI, if it has one satori can draw. */
271async function iconFor(env: Env, avatar: string | null | undefined): Promise<string | undefined> {
272 if (!avatar || !/^[0-9a-f]{64}$/.test(avatar)) return undefined;
273 try {
274 // An avatar's key is its hash: what is under it never changes.
275 const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(avatar, {
276 type: "arrayBuffer",
277 cacheTtl: 86_400,
278 });
279 const type = metadata?.contentType;
280 if (!value || !type || !DRAWABLE.has(type)) return undefined;
281 const bytes = new Uint8Array(value);
282 let binary = "";
283 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
284 return `data:${type};base64,${btoa(binary)}`;
285 } catch {
286 return undefined;
287 }
288}
289
290async function withIcon(card: Card, env: Env): Promise<Card> {
291 if (card.kind === "workspace" || card.kind === "person") return { ...card, icon: await iconFor(env, card.avatar) };
292 return card;
293}
294
295async function lookUpCard(url: URL, env: Env): Promise<Card> {
296 if (url.pathname === "/docs") return docsCard(url.searchParams);
297 if (url.pathname === "/") return BRAND;
298 // The page whose card it is, as the cache key has it (cache.ts).
299 return resolve(cardPath(url.searchParams.get("path") ?? "/"), {
300 identity: identityClient(env.IDENTITY),
301 repos: reposClient(env.REPOS),
302 work: workClient(env.WORK),
303 projects: projectsClient(env.PROJECTS),
304 });
305}