this repo has no description
at main 417 lines 13 kB view raw
1import { Agent } from "@atproto/api"; 2import { Hono } from "hono"; 3import { createOAuthClient } from "../lib/oauth-client"; 4import { getSessionDid, setReturnToCookie } from "../lib/session"; 5 6interface Env { 7 ASSETS: Fetcher; 8 SEQUOIA_SESSIONS: KVNamespace; 9 CLIENT_URL: string; 10} 11 12// Cache the vocs-generated stylesheet href across requests (changes on rebuild). 13let _vocsStyleHref: string | null = null; 14 15async function getVocsStyleHref( 16 assets: Fetcher, 17 baseUrl: string, 18): Promise<string> { 19 if (_vocsStyleHref) return _vocsStyleHref; 20 try { 21 const indexUrl = new URL("/", baseUrl).toString(); 22 const res = await assets.fetch(indexUrl); 23 const html = await res.text(); 24 const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/); 25 if (match?.[1]) { 26 _vocsStyleHref = match[1]; 27 return match[1]; 28 } 29 } catch { 30 // Fall back to the custom stylesheet which at least provides --sequoia-* vars 31 } 32 return "/styles.css"; 33} 34 35const subscribe = new Hono<{ Bindings: Env }>(); 36 37const COLLECTION = "site.standard.graph.subscription"; 38const REDIRECT_DELAY_SECONDS = 5; 39 40// ============================================================================ 41// Helpers 42// ============================================================================ 43 44/** 45 * Scan the user's repo for an existing site.standard.graph.subscription 46 * matching the given publication URI. Returns the record AT-URI if found. 47 */ 48async function findExistingSubscription( 49 agent: Agent, 50 did: string, 51 publicationUri: string, 52): Promise<string | null> { 53 let cursor: string | undefined; 54 55 do { 56 const result = await agent.com.atproto.repo.listRecords({ 57 repo: did, 58 collection: COLLECTION, 59 limit: 100, 60 cursor, 61 }); 62 63 for (const record of result.data.records) { 64 const value = record.value as { publication?: string }; 65 if (value.publication === publicationUri) { 66 return record.uri; 67 } 68 } 69 70 cursor = result.data.cursor; 71 } while (cursor); 72 73 return null; 74} 75 76// ============================================================================ 77// POST /subscribe 78// 79// Called via fetch() from the sequoia-subscribe web component. 80// Body JSON: { publicationUri: string } 81// 82// Responses: 83// 200 { subscribed: true, existing: boolean, recordUri: string } 84// 400 { error: string } 85// 401 { authenticated: false, subscribeUrl: string } 86// ============================================================================ 87 88subscribe.post("/", async (c) => { 89 let publicationUri: string; 90 try { 91 const body = await c.req.json<{ publicationUri?: string }>(); 92 publicationUri = body.publicationUri ?? ""; 93 } catch { 94 return c.json({ error: "Invalid JSON body" }, 400); 95 } 96 97 if (!publicationUri || !publicationUri.startsWith("at://")) { 98 return c.json({ error: "Missing or invalid publicationUri" }, 400); 99 } 100 101 const did = getSessionDid(c); 102 if (!did) { 103 const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 104 return c.json({ authenticated: false, subscribeUrl }, 401); 105 } 106 107 try { 108 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 109 const session = await client.restore(did); 110 const agent = new Agent(session); 111 112 const existingUri = await findExistingSubscription( 113 agent, 114 did, 115 publicationUri, 116 ); 117 if (existingUri) { 118 return c.json({ 119 subscribed: true, 120 existing: true, 121 recordUri: existingUri, 122 }); 123 } 124 125 const result = await agent.com.atproto.repo.createRecord({ 126 repo: did, 127 collection: COLLECTION, 128 record: { 129 $type: COLLECTION, 130 publication: publicationUri, 131 }, 132 }); 133 134 return c.json({ 135 subscribed: true, 136 existing: false, 137 recordUri: result.data.uri, 138 }); 139 } catch (error) { 140 console.error("Subscribe POST error:", error); 141 // Treat expired/missing session as unauthenticated 142 const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 143 return c.json({ authenticated: false, subscribeUrl }, 401); 144 } 145}); 146 147// ============================================================================ 148// GET /subscribe?publicationUri=at://... 149// 150// Full-page OAuth + subscription flow. Unauthenticated users land here after 151// the component redirects them, and authenticated users land here after the 152// OAuth callback (via the login_return_to cookie set in POST /subscribe/login). 153// ============================================================================ 154 155subscribe.get("/", async (c) => { 156 const publicationUri = c.req.query("publicationUri"); 157 const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 158 159 if (!publicationUri || !publicationUri.startsWith("at://")) { 160 return c.html( 161 renderError("Missing or invalid publication URI.", styleHref), 162 400, 163 ); 164 } 165 166 // Prefer an explicit returnTo query param (survives the OAuth round-trip); 167 // fall back to the Referer header on the first visit, ignoring self-referrals. 168 const referer = c.req.header("referer"); 169 const returnTo = 170 c.req.query("returnTo") ?? 171 (referer && !referer.includes("/subscribe") ? referer : undefined); 172 173 const did = getSessionDid(c); 174 if (!did) { 175 return c.html(renderHandleForm(publicationUri, styleHref, returnTo)); 176 } 177 178 try { 179 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 180 const session = await client.restore(did); 181 const agent = new Agent(session); 182 183 const existingUri = await findExistingSubscription( 184 agent, 185 did, 186 publicationUri, 187 ); 188 if (existingUri) { 189 return c.html( 190 renderSuccess(publicationUri, existingUri, true, styleHref, returnTo), 191 ); 192 } 193 194 const result = await agent.com.atproto.repo.createRecord({ 195 repo: did, 196 collection: COLLECTION, 197 record: { 198 $type: COLLECTION, 199 publication: publicationUri, 200 }, 201 }); 202 203 return c.html( 204 renderSuccess( 205 publicationUri, 206 result.data.uri, 207 false, 208 styleHref, 209 returnTo, 210 ), 211 ); 212 } catch (error) { 213 console.error("Subscribe GET error:", error); 214 // Session expired - ask the user to sign in again 215 return c.html( 216 renderHandleForm( 217 publicationUri, 218 styleHref, 219 returnTo, 220 "Session expired. Please sign in again.", 221 ), 222 ); 223 } 224}); 225 226// ============================================================================ 227// POST /subscribe/login 228// 229// Handles the handle-entry form submission. Stores the return URL in a cookie 230// so the OAuth callback in auth.ts can redirect back to /subscribe after auth. 231// ============================================================================ 232 233subscribe.post("/login", async (c) => { 234 const body = await c.req.parseBody(); 235 const handle = (body["handle"] as string | undefined)?.trim(); 236 const publicationUri = body["publicationUri"] as string | undefined; 237 const formReturnTo = (body["returnTo"] as string | undefined) || undefined; 238 239 if (!handle || !publicationUri) { 240 const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 241 return c.html( 242 renderError("Missing handle or publication URI.", styleHref), 243 400, 244 ); 245 } 246 247 const returnTo = 248 `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + 249 (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); 250 setReturnToCookie(c, returnTo, c.env.CLIENT_URL); 251 252 return c.redirect( 253 `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, 254 ); 255}); 256 257// ============================================================================ 258// HTML rendering 259// ============================================================================ 260 261function renderHandleForm( 262 publicationUri: string, 263 styleHref: string, 264 returnTo?: string, 265 error?: string, 266): string { 267 const errorHtml = error 268 ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` 269 : ""; 270 const returnToInput = returnTo 271 ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` 272 : ""; 273 274 return page( 275 ` 276 <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1> 277 <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p> 278 ${errorHtml} 279 <form method="POST" action="/subscribe/login"> 280 <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> 281 ${returnToInput} 282 <input 283 type="text" 284 name="handle" 285 placeholder="you.bsky.social" 286 autocomplete="username" 287 required 288 autofocus 289 /> 290 <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> 291 </form> 292 `, 293 styleHref, 294 ); 295} 296 297function renderSuccess( 298 publicationUri: string, 299 recordUri: string, 300 existing: boolean, 301 styleHref: string, 302 returnTo?: string, 303): string { 304 const msg = existing 305 ? "You're already subscribed to this publication." 306 : "You've successfully subscribed!"; 307 const escapedPublicationUri = escapeHtml(publicationUri); 308 const escapedRecordUri = escapeHtml(recordUri); 309 310 const redirectHtml = returnTo 311 ? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapeHtml(returnTo)}">${escapeHtml(returnTo)}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> 312 <script> 313 (function(){ 314 var secs = ${REDIRECT_DELAY_SECONDS}; 315 var el = document.getElementById('countdown'); 316 var iv = setInterval(function(){ 317 secs--; 318 if (el) el.textContent = String(secs); 319 if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } 320 }, 1000); 321 })(); 322 </script>` 323 : ""; 324 const headExtra = returnTo 325 ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapeHtml(returnTo)}" />` 326 : ""; 327 328 return page( 329 ` 330 <h1 class="vocs_H1 vocs_Heading">Subscribed ✓</h1> 331 <p class="vocs_Paragraph">${msg}</p> 332 ${redirectHtml} 333 <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;"> 334 <colgroup><col style="width:7rem;"><col></colgroup> 335 <tbody> 336 <tr class="vocs_TableRow"> 337 <td class="vocs_TableCell">Publication</td> 338 <td class="vocs_TableCell" style="overflow:hidden;"> 339 <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> 340 </td> 341 </tr> 342 <tr class="vocs_TableRow"> 343 <td class="vocs_TableCell">Record</td> 344 <td class="vocs_TableCell" style="overflow:hidden;"> 345 <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></div> 346 </td> 347 </tr> 348 </tbody> 349 </table> 350 `, 351 styleHref, 352 headExtra, 353 ); 354} 355 356function renderError(message: string, styleHref: string): string { 357 return page( 358 `<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, 359 styleHref, 360 ); 361} 362 363function page(body: string, styleHref: string, headExtra = ""): string { 364 return `<!DOCTYPE html> 365<html lang="en"> 366<head> 367 <meta charset="UTF-8" /> 368 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 369 <title>Sequoia · Subscribe</title> 370 <link rel="stylesheet" href="${styleHref}" /> 371 <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> 372 ${headExtra} 373 <style> 374 .page-container { 375 max-width: calc(var(--vocs-content_width, 480px) / 1.6); 376 margin: 4rem auto; 377 padding: 0 var(--vocs-space_20, 1.25rem); 378 } 379 .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } 380 .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } 381 input[type="text"] { 382 padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); 383 border: 1px solid var(--vocs-color_border, #D5D1C8); 384 border-radius: var(--vocs-borderRadius_6, 6px); 385 margin-bottom: var(--vocs-space_20, 1.25rem); 386 min-width: 30vh; 387 width: 100%; 388 font-size: var(--vocs-fontSize_16, 1rem); 389 font-family: inherit; 390 background: var(--vocs-color_background, #F5F3EF); 391 color: var(--vocs-color_text, #2C2C2C); 392 } 393 input[type="text"]:focus { 394 border-color: var(--vocs-color_borderAccent, #3A5A40); 395 outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); 396 outline-offset: 2px; 397 } 398 .error { color: var(--vocs-color_dangerText, #8B3A3A); } 399 </style> 400</head> 401<body> 402 <div class="page-container"> 403 ${body} 404 </div> 405</body> 406</html>`; 407} 408 409function escapeHtml(text: string): string { 410 return text 411 .replace(/&/g, "&amp;") 412 .replace(/</g, "&lt;") 413 .replace(/>/g, "&gt;") 414 .replace(/"/g, "&quot;"); 415} 416 417export default subscribe;