| 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=§ion=&description= |
| 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 by their full address; the site adds `v`, which changes |
| 11 | * when what a card shows does, so an edited title gets a new card. |
| 12 | */ |
| 13 | import { type ServiceBinding, identityClient, projectsClient, reposClient, workClient } from "@g1t/contracts"; |
| 14 | import type { Font } from "satori/standalone"; |
| 15 | import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm"; |
| 16 | import yogaWasm from "satori/yoga.wasm"; |
| 17 | |
| 18 | import inter400 from "./fonts/inter-400.ttf"; |
| 19 | import inter500 from "./fonts/inter-500.ttf"; |
| 20 | import inter600 from "./fonts/inter-600.ttf"; |
| 21 | import inter700 from "./fonts/inter-700.ttf"; |
| 22 | import mono400 from "./fonts/jetbrains-mono-400.ttf"; |
| 23 | import mono500 from "./fonts/jetbrains-mono-500.ttf"; |
| 24 | import { cardPng } from "./render.ts"; |
| 25 | import { cacheKey } from "./cache.ts"; |
| 26 | import { BRAND, type Card, docsCard, resolve } from "./resolve.ts"; |
| 27 | |
| 28 | interface Env { |
| 29 | /** Uploaded avatars by hash, with `{ contentType }`; written by identity. */ |
| 30 | AVATARS: KVNamespace; |
| 31 | IDENTITY: ServiceBinding; |
| 32 | REPOS: ServiceBinding; |
| 33 | WORK: ServiceBinding; |
| 34 | PROJECTS: ServiceBinding; |
| 35 | } |
| 36 | |
| 37 | const FONTS: Font[] = [ |
| 38 | { name: "Inter", data: inter400, weight: 400, style: "normal" }, |
| 39 | { name: "Inter", data: inter500, weight: 500, style: "normal" }, |
| 40 | { name: "Inter", data: inter600, weight: 600, style: "normal" }, |
| 41 | { name: "Inter", data: inter700, weight: 700, style: "normal" }, |
| 42 | { name: "JetBrains Mono", data: mono400, weight: 400, style: "normal" }, |
| 43 | { name: "JetBrains Mono", data: mono500, weight: 500, style: "normal" }, |
| 44 | ]; |
| 45 | |
| 46 | const ASSETS = { yoga: yogaWasm, resvg: resvgWasm, fonts: FONTS }; |
| 47 | |
| 48 | /** Kept an hour by browsers and a day at the edge. */ |
| 49 | const CACHE_CONTROL = "public, max-age=3600, s-maxage=86400"; |
| 50 | /** When a service could not be reached: soon tried again. */ |
| 51 | const BRIEF_CACHE_CONTROL = "public, max-age=60"; |
| 52 | |
| 53 | /** The static card, for when rendering itself fails. */ |
| 54 | const FALLBACK = "https://g1t.sh/brand/g1t-og.png"; |
| 55 | |
| 56 | export default { |
| 57 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { |
| 58 | const url = new URL(request.url); |
| 59 | if (request.method !== "GET" && request.method !== "HEAD") { |
| 60 | return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } }); |
| 61 | } |
| 62 | if (url.pathname === "/health") return new Response("ok"); |
| 63 | if (url.pathname !== "/" && url.pathname !== "/image" && url.pathname !== "/docs") { |
| 64 | return new Response("Not found", { status: 404 }); |
| 65 | } |
| 66 | |
| 67 | const key = cacheKey(url); |
| 68 | const cache = (caches as unknown as { default: Cache }).default; |
| 69 | const hit = await cache.match(key); |
| 70 | if (hit) return hit; |
| 71 | |
| 72 | const card = await cardFor(url, env); |
| 73 | const failed = card.kind === "brand" && card.failed === true; |
| 74 | // Every page without a card of its own shares the one brand card, so |
| 75 | // made-up paths cannot make the service draw it again and again. |
| 76 | const brandKey = cacheKey(new URL("/", url)); |
| 77 | if (card.kind === "brand" && key !== brandKey) { |
| 78 | const brand = await cache.match(brandKey); |
| 79 | if (brand) return withCacheControl(brand, failed); |
| 80 | } |
| 81 | |
| 82 | let png: Uint8Array; |
| 83 | try { |
| 84 | png = await cardPng(card, ASSETS); |
| 85 | } catch (error) { |
| 86 | console.error("og: rendering failed", url.pathname, error); |
| 87 | // An icon it could not draw is left out rather than losing the card. |
| 88 | if ((card.kind !== "workspace" && card.kind !== "person") || !card.icon) return Response.redirect(FALLBACK, 302); |
| 89 | try { |
| 90 | png = await cardPng({ ...card, icon: undefined }, ASSETS); |
| 91 | } catch { |
| 92 | return Response.redirect(FALLBACK, 302); |
| 93 | } |
| 94 | } |
| 95 | const response = new Response(png, { |
| 96 | headers: { |
| 97 | "content-type": "image/png", |
| 98 | "cache-control": CACHE_CONTROL, |
| 99 | "access-control-allow-origin": "*", |
| 100 | "x-content-type-options": "nosniff", |
| 101 | }, |
| 102 | }); |
| 103 | if (card.kind === "brand") { |
| 104 | ctx.waitUntil(cache.put(brandKey, response.clone())); |
| 105 | } else { |
| 106 | ctx.waitUntil(cache.put(key, response.clone())); |
| 107 | } |
| 108 | return withCacheControl(response, failed); |
| 109 | }, |
| 110 | } satisfies ExportedHandler<Env>; |
| 111 | |
| 112 | /** A card shown because a service failed is kept only briefly, by anyone. */ |
| 113 | function withCacheControl(response: Response, failed: boolean): Response { |
| 114 | if (!failed) return response; |
| 115 | const brief = new Response(response.body, response); |
| 116 | brief.headers.set("cache-control", BRIEF_CACHE_CONTROL); |
| 117 | return brief; |
| 118 | } |
| 119 | |
| 120 | /** What satori can draw: PNG and JPEG. A WebP or GIF icon is left off the card. */ |
| 121 | const DRAWABLE = new Set(["image/png", "image/jpeg"]); |
| 122 | |
| 123 | /** A workspace's or person's uploaded icon as a data URI, if it has one satori can draw. */ |
| 124 | async function iconFor(env: Env, avatar: string | null | undefined): Promise<string | undefined> { |
| 125 | if (!avatar || !/^[0-9a-f]{64}$/.test(avatar)) return undefined; |
| 126 | try { |
| 127 | const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(avatar, { |
| 128 | type: "arrayBuffer", |
| 129 | }); |
| 130 | const type = metadata?.contentType; |
| 131 | if (!value || !type || !DRAWABLE.has(type)) return undefined; |
| 132 | const bytes = new Uint8Array(value); |
| 133 | let binary = ""; |
| 134 | for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); |
| 135 | return `data:${type};base64,${btoa(binary)}`; |
| 136 | } catch { |
| 137 | return undefined; |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | async function cardFor(url: URL, env: Env): Promise<Card> { |
| 142 | const card = await lookUpCard(url, env); |
| 143 | if (card.kind === "workspace" || card.kind === "person") return { ...card, icon: await iconFor(env, card.avatar) }; |
| 144 | return card; |
| 145 | } |
| 146 | |
| 147 | async function lookUpCard(url: URL, env: Env): Promise<Card> { |
| 148 | if (url.pathname === "/docs") return docsCard(url.searchParams); |
| 149 | if (url.pathname === "/") return BRAND; |
| 150 | return resolve(url.searchParams.get("path") ?? "/", { |
| 151 | identity: identityClient(env.IDENTITY), |
| 152 | repos: reposClient(env.REPOS), |
| 153 | work: workClient(env.WORK), |
| 154 | projects: projectsClient(env.PROJECTS), |
| 155 | }); |
| 156 | } |