Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
1import { ImageResponse, loadGoogleFont } from "workers-og";
2import heroLogoSvg from "./hero-light.svg";
3
4const SLINGSHOT_URL = "https://slingshot.microcosm.blue/xrpc";
5
6const DEFAULT_TITLE = "atbbs";
7const DEFAULT_DESCRIPTION = "Decentralized forums on the AT Protocol.";
8
9// Tailwind neutral palette — matches the site's light theme.
10const COLORS = {
11 background: "#fafafa", // neutral-50
12 title: "#171717", // neutral-900
13 subtitle: "#525252", // neutral-600
14 description: "#525252", // neutral-600
15};
16
17// Types
18
19interface Route {
20 type: "bbs" | "board" | "thread" | "news";
21 handle: string;
22 slug?: string;
23 did?: string;
24 rkey?: string;
25}
26
27interface Metadata {
28 title: string;
29 subtitle: string;
30 description: string;
31}
32
33interface SlingshotIdentity {
34 did: string;
35 handle: string;
36 pds?: string;
37}
38
39interface SlingshotRecord {
40 uri: string;
41 cid: string;
42 value: Record<string, string>;
43}
44
45// Utils
46
47function escapeHtml(text: string): string {
48 return text
49 .replace(/&/g, "&")
50 .replace(/</g, "<")
51 .replace(/>/g, ">")
52 .replace(/"/g, """);
53}
54
55function truncate(text: string, maxLength: number): string {
56 if (text.length <= maxLength) return text;
57 return text.substring(0, maxLength - 3) + "...";
58}
59
60// Slingshot
61
62async function resolveIdentity(
63 handle: string,
64): Promise<SlingshotIdentity | null> {
65 const response = await fetch(
66 `${SLINGSHOT_URL}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`,
67 );
68 if (!response.ok) return null;
69 return (await response.json()) as SlingshotIdentity;
70}
71
72async function fetchRecord(
73 did: string,
74 collection: string,
75 recordKey: string,
76): Promise<SlingshotRecord | null> {
77 const response = await fetch(
78 `${SLINGSHOT_URL}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(recordKey)}`,
79 );
80 if (!response.ok) return null;
81 return (await response.json()) as SlingshotRecord;
82}
83
84async function fetchSiteName(did: string, fallback: string): Promise<string> {
85 const siteRecord = await fetchRecord(did, "xyz.atbbs.site", "self");
86 return siteRecord ? siteRecord.value.name : fallback;
87}
88
89// --- Route parsing ---
90
91function parseRoute(path: string): Route | null {
92 // Strip /og prefix and .png suffix so this works for both HTML and image routes.
93 const normalizedPath = path.replace(/^\/og/, "").replace(/\.png$/, "");
94
95 const bbsMatch = normalizedPath.match(/^\/bbs\/([^/]+)$/);
96 if (bbsMatch) return { type: "bbs", handle: bbsMatch[1] };
97
98 const boardMatch = normalizedPath.match(/^\/bbs\/([^/]+)\/board\/([^/]+)$/);
99 if (boardMatch)
100 return { type: "board", handle: boardMatch[1], slug: boardMatch[2] };
101
102 const threadMatch = normalizedPath.match(
103 /^\/bbs\/([^/]+)\/thread\/([^/]+)\/([^/]+)$/,
104 );
105 if (threadMatch)
106 return {
107 type: "thread",
108 handle: threadMatch[1],
109 did: threadMatch[2],
110 rkey: threadMatch[3],
111 };
112
113 const newsMatch = normalizedPath.match(/^\/bbs\/([^/]+)\/news\/([^/]+)$/);
114 if (newsMatch)
115 return { type: "news", handle: newsMatch[1], rkey: newsMatch[2] };
116
117 return null;
118}
119
120// Metadata
121
122async function fetchMetadata(route: Route): Promise<Metadata | null> {
123 const identity = await resolveIdentity(route.handle);
124 if (!identity) return null;
125
126 if (route.type === "bbs") {
127 const siteRecord = await fetchRecord(
128 identity.did,
129 "xyz.atbbs.site",
130 "self",
131 );
132 if (siteRecord) {
133 return {
134 title: siteRecord.value.name,
135 subtitle: "",
136 description: siteRecord.value.description || "",
137 };
138 }
139 } else if (route.type === "board") {
140 const siteName = await fetchSiteName(identity.did, route.handle);
141 const boardRecord = await fetchRecord(
142 identity.did,
143 "xyz.atbbs.board",
144 route.slug!,
145 );
146 if (boardRecord) {
147 return {
148 title: boardRecord.value.name,
149 subtitle: siteName,
150 description: boardRecord.value.description || "",
151 };
152 }
153 } else if (route.type === "thread") {
154 const siteName = await fetchSiteName(identity.did, route.handle);
155 const postRecord = await fetchRecord(
156 route.did!,
157 "xyz.atbbs.post",
158 route.rkey!,
159 );
160 if (postRecord) {
161 return {
162 title: postRecord.value.title || "Thread",
163 subtitle: siteName,
164 description: postRecord.value.body || "",
165 };
166 }
167 } else if (route.type === "news") {
168 const siteName = await fetchSiteName(identity.did, route.handle);
169 const postRecord = await fetchRecord(
170 identity.did,
171 "xyz.atbbs.post",
172 route.rkey!,
173 );
174 if (postRecord) {
175 return {
176 title: postRecord.value.title || "News",
177 subtitle: siteName,
178 description: postRecord.value.body || "",
179 };
180 }
181 }
182
183 return null;
184}
185
186// Generate OG Images
187
188const HERO_LOGO_DATA_URI =
189 "data:image/svg+xml," + encodeURIComponent(heroLogoSvg);
190
191async function renderOgImage(
192 title: string,
193 subtitle: string,
194 description: string,
195): Promise<Response> {
196 const displayTitle = escapeHtml(truncate(title, 40));
197 const displaySubtitle = escapeHtml(subtitle);
198 const displayDescription = escapeHtml(truncate(description, 120));
199
200 const fontData = await loadGoogleFont({
201 family: "Geist Mono",
202 weight: 400,
203 });
204
205 const subtitleHtml = displaySubtitle
206 ? `<div style="display: flex; font-size: 24px; color: ${COLORS.subtitle}; font-family: 'Geist Mono';">${displaySubtitle}</div>`
207 : "";
208
209 const html = `
210 <div style="display: flex; flex-direction: column; justify-content: space-between; width: 1200px; height: 630px; background-color: ${COLORS.background}; padding: 80px 90px;">
211 <img src="${HERO_LOGO_DATA_URI}" width="276" height="84" style="image-rendering: pixelated;" />
212 <div style="display: flex; flex-direction: column; gap: 12px;">
213 ${subtitleHtml}
214 <div style="display: flex; font-size: 56px; color: ${COLORS.title}; font-family: 'Geist Mono'; line-height: 1.2;">${displayTitle}</div>
215 <div style="display: flex; font-size: 22px; color: ${COLORS.description}; font-family: 'Geist Mono'; line-height: 1.4;">${displayDescription}</div>
216 </div>
217 </div>
218 `;
219
220 return new ImageResponse(html, {
221 width: 1200,
222 height: 630,
223 fonts: [
224 {
225 name: "Geist Mono",
226 data: fontData,
227 style: "normal",
228 weight: 400,
229 },
230 ],
231 });
232}
233
234// Inject HTML
235
236function injectMetadata(
237 html: string,
238 title: string,
239 description: string,
240 pageUrl: string,
241 imageUrl: string,
242): string {
243 const safeTitle = escapeHtml(title);
244 const safeDescription = escapeHtml(description.substring(0, 200));
245 const safePageUrl = escapeHtml(pageUrl);
246 const safeImageUrl = escapeHtml(imageUrl);
247
248 html = html.replace(
249 "<title>atbbs</title>",
250 `<title>${safeTitle}</title>`,
251 );
252 html = html.replace(
253 '<meta property="og:title" content="atbbs" />',
254 `<meta property="og:title" content="${safeTitle}" />`,
255 );
256 html = html.replace(
257 '<meta property="og:description" content="Decentralized forums on the AT Protocol." />',
258 `<meta property="og:description" content="${safeDescription}" />`,
259 );
260 html = html.replace(
261 '<meta property="og:image" content="/og.png" />',
262 `<meta property="og:image" content="${safeImageUrl}" />`,
263 );
264
265 if (!html.includes("og:url")) {
266 html = html.replace(
267 '<meta property="og:type"',
268 `<meta property="og:url" content="${safePageUrl}" />\n <meta property="og:type"`,
269 );
270 }
271
272 return html;
273}
274
275// Entry point
276
277export default {
278 async fetch(request: Request): Promise<Response> {
279 const url = new URL(request.url);
280 const path = url.pathname;
281
282 // Dynamic og:image at /og/bbs/... — cached at the edge for 1 hour.
283 if (path.startsWith("/og/bbs/")) {
284 const cache = caches.default;
285 const cachedResponse = await cache.match(request);
286 if (cachedResponse) return cachedResponse;
287
288 const route = parseRoute(path);
289 let imageResponse: Response;
290
291 try {
292 const metadata = route ? await fetchMetadata(route) : null;
293 imageResponse = metadata
294 ? await renderOgImage(metadata.title, metadata.subtitle, metadata.description)
295 : await renderOgImage(DEFAULT_TITLE, "", DEFAULT_DESCRIPTION);
296 } catch {
297 imageResponse = await renderOgImage(DEFAULT_TITLE, "", DEFAULT_DESCRIPTION);
298 }
299
300 const cachedCopy = new Response(imageResponse.body, imageResponse);
301 cachedCopy.headers.set("Cache-Control", "public, max-age=3600");
302 await cache.put(request, cachedCopy.clone());
303 return cachedCopy;
304 }
305
306 // Inject metadata into HTML for /bbs/... routes.
307 const route = parseRoute(path);
308 const originResponse = await fetch(request);
309 const contentType = originResponse.headers.get("content-type") || "";
310
311 if (!route || !contentType.includes("text/html")) {
312 return originResponse;
313 }
314
315 let html = await originResponse.text();
316
317 try {
318 const metadata = await fetchMetadata(route);
319 if (metadata) {
320 const fullTitle = metadata.subtitle
321 ? `${metadata.title} \u2014 ${metadata.subtitle}`
322 : metadata.title;
323 const imageUrl = `${url.origin}/og${path}.png`;
324 html = injectMetadata(
325 html,
326 fullTitle,
327 metadata.description,
328 url.toString(),
329 imageUrl,
330 );
331 }
332 } catch {
333 // On any error, serve the original HTML unmodified.
334 }
335
336 return new Response(html, {
337 status: originResponse.status,
338 headers: originResponse.headers,
339 });
340 },
341};