Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 341 lines 9.7 kB view raw
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, "&amp;") 50 .replace(/</g, "&lt;") 51 .replace(/>/g, "&gt;") 52 .replace(/"/g, "&quot;"); 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};