flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/og/src/index.ts

156 lines6,337 bytesCodeBlame
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=
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 */
13import { type ServiceBinding, identityClient, projectsClient, reposClient, workClient } from "@g1t/contracts";
14import type { Font } from "satori/standalone";
15import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
16import yogaWasm from "satori/yoga.wasm";
17
18import inter400 from "./fonts/inter-400.ttf";
19import inter500 from "./fonts/inter-500.ttf";
20import inter600 from "./fonts/inter-600.ttf";
21import inter700 from "./fonts/inter-700.ttf";
22import mono400 from "./fonts/jetbrains-mono-400.ttf";
23import mono500 from "./fonts/jetbrains-mono-500.ttf";
24import { cardPng } from "./render.ts";
25import { cacheKey } from "./cache.ts";
26import { BRAND, type Card, docsCard, resolve } from "./resolve.ts";
27
28interface 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
37const 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
46const ASSETS = { yoga: yogaWasm, resvg: resvgWasm, fonts: FONTS };
47
48/** Kept an hour by browsers and a day at the edge. */
49const CACHE_CONTROL = "public, max-age=3600, s-maxage=86400";
50/** When a service could not be reached: soon tried again. */
51const BRIEF_CACHE_CONTROL = "public, max-age=60";
52
53/** The static card, for when rendering itself fails. */
54const FALLBACK = "https://g1t.sh/brand/g1t-og.png";
55
56export 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. */
113function 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. */
121const 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. */
124async 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
141async 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
147async 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}