| 1 | import { Agent } from "@atproto/api"; |
| 2 | import { Hono } from "hono"; |
| 3 | import { createOAuthClient } from "../lib/oauth-client"; |
| 4 | import { getSessionDid, setReturnToCookie } from "../lib/session"; |
| 5 | |
| 6 | interface 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). |
| 13 | let _vocsStyleHref: string | null = null; |
| 14 | |
| 15 | async function getVocsStyleHref(assets: Fetcher, baseUrl: string): Promise<string> { |
| 16 | if (_vocsStyleHref) return _vocsStyleHref; |
| 17 | try { |
| 18 | const indexUrl = new URL("/", baseUrl).toString(); |
| 19 | const res = await assets.fetch(indexUrl); |
| 20 | const html = await res.text(); |
| 21 | const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/); |
| 22 | if (match?.[1]) { |
| 23 | _vocsStyleHref = match[1]; |
| 24 | return match[1]; |
| 25 | } |
| 26 | } catch { |
| 27 | // Fall back to the custom stylesheet which at least provides --sequoia-* vars |
| 28 | } |
| 29 | return "/styles.css"; |
| 30 | } |
| 31 | |
| 32 | const subscribe = new Hono<{ Bindings: Env }>(); |
| 33 | |
| 34 | const COLLECTION = "site.standard.graph.subscription"; |
| 35 | |
| 36 | // ============================================================================ |
| 37 | // Helpers |
| 38 | // ============================================================================ |
| 39 | |
| 40 | /** |
| 41 | * Scan the user's repo for an existing site.standard.graph.subscription |
| 42 | * matching the given publication URI. Returns the record AT-URI if found. |
| 43 | */ |
| 44 | async function findExistingSubscription( |
| 45 | agent: Agent, |
| 46 | did: string, |
| 47 | publicationUri: string, |
| 48 | ): Promise<string | null> { |
| 49 | let cursor: string | undefined; |
| 50 | |
| 51 | do { |
| 52 | const result = await agent.com.atproto.repo.listRecords({ |
| 53 | repo: did, |
| 54 | collection: COLLECTION, |
| 55 | limit: 100, |
| 56 | cursor, |
| 57 | }); |
| 58 | |
| 59 | for (const record of result.data.records) { |
| 60 | const value = record.value as { publication?: string }; |
| 61 | if (value.publication === publicationUri) { |
| 62 | return record.uri; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | cursor = result.data.cursor; |
| 67 | } while (cursor); |
| 68 | |
| 69 | return null; |
| 70 | } |
| 71 | |
| 72 | // ============================================================================ |
| 73 | // POST /subscribe |
| 74 | // |
| 75 | // Called via fetch() from the sequoia-subscribe web component. |
| 76 | // Body JSON: { publicationUri: string } |
| 77 | // |
| 78 | // Responses: |
| 79 | // 200 { subscribed: true, existing: boolean, recordUri: string } |
| 80 | // 400 { error: string } |
| 81 | // 401 { authenticated: false, subscribeUrl: string } |
| 82 | // ============================================================================ |
| 83 | |
| 84 | subscribe.post("/", async (c) => { |
| 85 | let publicationUri: string; |
| 86 | try { |
| 87 | const body = await c.req.json<{ publicationUri?: string }>(); |
| 88 | publicationUri = body.publicationUri ?? ""; |
| 89 | } catch { |
| 90 | return c.json({ error: "Invalid JSON body" }, 400); |
| 91 | } |
| 92 | |
| 93 | if (!publicationUri || !publicationUri.startsWith("at://")) { |
| 94 | return c.json({ error: "Missing or invalid publicationUri" }, 400); |
| 95 | } |
| 96 | |
| 97 | const did = getSessionDid(c); |
| 98 | if (!did) { |
| 99 | const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; |
| 100 | return c.json({ authenticated: false, subscribeUrl }, 401); |
| 101 | } |
| 102 | |
| 103 | try { |
| 104 | const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); |
| 105 | const session = await client.restore(did); |
| 106 | const agent = new Agent(session); |
| 107 | |
| 108 | const existingUri = await findExistingSubscription(agent, did, publicationUri); |
| 109 | if (existingUri) { |
| 110 | return c.json({ subscribed: true, existing: true, recordUri: existingUri }); |
| 111 | } |
| 112 | |
| 113 | const result = await agent.com.atproto.repo.createRecord({ |
| 114 | repo: did, |
| 115 | collection: COLLECTION, |
| 116 | record: { |
| 117 | $type: COLLECTION, |
| 118 | publication: publicationUri, |
| 119 | }, |
| 120 | }); |
| 121 | |
| 122 | return c.json({ subscribed: true, existing: false, recordUri: result.data.uri }); |
| 123 | } catch (error) { |
| 124 | console.error("Subscribe POST error:", error); |
| 125 | // Treat expired/missing session as unauthenticated |
| 126 | const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; |
| 127 | return c.json({ authenticated: false, subscribeUrl }, 401); |
| 128 | } |
| 129 | }); |
| 130 | |
| 131 | // ============================================================================ |
| 132 | // GET /subscribe?publicationUri=at://... |
| 133 | // |
| 134 | // Full-page OAuth + subscription flow. Unauthenticated users land here after |
| 135 | // the component redirects them, and authenticated users land here after the |
| 136 | // OAuth callback (via the login_return_to cookie set in POST /subscribe/login). |
| 137 | // ============================================================================ |
| 138 | |
| 139 | subscribe.get("/", async (c) => { |
| 140 | const publicationUri = c.req.query("publicationUri"); |
| 141 | const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); |
| 142 | |
| 143 | if (!publicationUri || !publicationUri.startsWith("at://")) { |
| 144 | return c.html(renderError("Missing or invalid publication URI.", styleHref), 400); |
| 145 | } |
| 146 | |
| 147 | const did = getSessionDid(c); |
| 148 | if (!did) { |
| 149 | return c.html(renderHandleForm(publicationUri, styleHref)); |
| 150 | } |
| 151 | |
| 152 | try { |
| 153 | const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); |
| 154 | const session = await client.restore(did); |
| 155 | const agent = new Agent(session); |
| 156 | |
| 157 | const existingUri = await findExistingSubscription(agent, did, publicationUri); |
| 158 | if (existingUri) { |
| 159 | return c.html(renderSuccess(publicationUri, existingUri, true, styleHref)); |
| 160 | } |
| 161 | |
| 162 | const result = await agent.com.atproto.repo.createRecord({ |
| 163 | repo: did, |
| 164 | collection: COLLECTION, |
| 165 | record: { |
| 166 | $type: COLLECTION, |
| 167 | publication: publicationUri, |
| 168 | }, |
| 169 | }); |
| 170 | |
| 171 | return c.html(renderSuccess(publicationUri, result.data.uri, false, styleHref)); |
| 172 | } catch (error) { |
| 173 | console.error("Subscribe GET error:", error); |
| 174 | // Session expired - ask the user to sign in again |
| 175 | return c.html(renderHandleForm(publicationUri, styleHref, "Session expired. Please sign in again.")); |
| 176 | } |
| 177 | }); |
| 178 | |
| 179 | // ============================================================================ |
| 180 | // POST /subscribe/login |
| 181 | // |
| 182 | // Handles the handle-entry form submission. Stores the return URL in a cookie |
| 183 | // so the OAuth callback in auth.ts can redirect back to /subscribe after auth. |
| 184 | // ============================================================================ |
| 185 | |
| 186 | subscribe.post("/login", async (c) => { |
| 187 | const body = await c.req.parseBody(); |
| 188 | const handle = (body["handle"] as string | undefined)?.trim(); |
| 189 | const publicationUri = body["publicationUri"] as string | undefined; |
| 190 | |
| 191 | if (!handle || !publicationUri) { |
| 192 | const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); |
| 193 | return c.html(renderError("Missing handle or publication URI.", styleHref), 400); |
| 194 | } |
| 195 | |
| 196 | const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; |
| 197 | setReturnToCookie(c, returnTo, c.env.CLIENT_URL); |
| 198 | |
| 199 | return c.redirect( |
| 200 | `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, |
| 201 | ); |
| 202 | }); |
| 203 | |
| 204 | // ============================================================================ |
| 205 | // HTML rendering |
| 206 | // ============================================================================ |
| 207 | |
| 208 | function renderHandleForm(publicationUri: string, styleHref: string, error?: string): string { |
| 209 | const errorHtml = error |
| 210 | ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` |
| 211 | : ""; |
| 212 | |
| 213 | return page(` |
| 214 | <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1> |
| 215 | <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p> |
| 216 | ${errorHtml} |
| 217 | <form method="POST" action="/subscribe/login"> |
| 218 | <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> |
| 219 | <input |
| 220 | type="text" |
| 221 | name="handle" |
| 222 | placeholder="you.bsky.social" |
| 223 | autocomplete="username" |
| 224 | required |
| 225 | autofocus |
| 226 | /> |
| 227 | <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> |
| 228 | </form> |
| 229 | `, styleHref); |
| 230 | } |
| 231 | |
| 232 | function renderSuccess( |
| 233 | publicationUri: string, |
| 234 | recordUri: string, |
| 235 | existing: boolean, |
| 236 | styleHref: string, |
| 237 | ): string { |
| 238 | const msg = existing |
| 239 | ? "You're already subscribed to this publication." |
| 240 | : "You've successfully subscribed!"; |
| 241 | const escapedPublicationUri = escapeHtml(publicationUri); |
| 242 | const escapedRecordUri = escapeHtml(recordUri); |
| 243 | return page(` |
| 244 | <h1 class="vocs_H1 vocs_Heading">Subscribed โ</h1> |
| 245 | <p class="vocs_Paragraph">${msg}</p> |
| 246 | <p class="vocs_Paragraph"><small>Publication: <code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></small></p> |
| 247 | <p class="vocs_Paragraph"><small>Record: <code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></small></p> |
| 248 | `, styleHref); |
| 249 | } |
| 250 | |
| 251 | function renderError(message: string, styleHref: string): string { |
| 252 | return page(`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, styleHref); |
| 253 | } |
| 254 | |
| 255 | function page(body: string, styleHref: string): string { |
| 256 | return `<!DOCTYPE html> |
| 257 | <html lang="en"> |
| 258 | <head> |
| 259 | <meta charset="UTF-8" /> |
| 260 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 261 | <title>Sequoia ยท Subscribe</title> |
| 262 | <link rel="stylesheet" href="${styleHref}" /> |
| 263 | <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> |
| 264 | <style> |
| 265 | .page-container { |
| 266 | max-width: calc(var(--vocs-content_width, 480px) / 1.6); |
| 267 | margin: 4rem auto; |
| 268 | padding: 0 var(--vocs-space_20, 1.25rem); |
| 269 | } |
| 270 | .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } |
| 271 | .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } |
| 272 | input[type="text"] { |
| 273 | padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); |
| 274 | border: 1px solid var(--vocs-color_border, #D5D1C8); |
| 275 | border-radius: var(--vocs-borderRadius_6, 6px); |
| 276 | margin-bottom: var(--vocs-space_20, 1.25rem); |
| 277 | min-width: 30vh; |
| 278 | width: 100%; |
| 279 | font-size: var(--vocs-fontSize_16, 1rem); |
| 280 | font-family: inherit; |
| 281 | background: var(--vocs-color_background, #F5F3EF); |
| 282 | color: var(--vocs-color_text, #2C2C2C); |
| 283 | } |
| 284 | input[type="text"]:focus { |
| 285 | border-color: var(--vocs-color_borderAccent, #3A5A40); |
| 286 | outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); |
| 287 | outline-offset: 2px; |
| 288 | } |
| 289 | .error { color: var(--vocs-color_dangerText, #8B3A3A); } |
| 290 | </style> |
| 291 | </head> |
| 292 | <body> |
| 293 | <div class="page-container"> |
| 294 | ${body} |
| 295 | </div> |
| 296 | </body> |
| 297 | </html>`; |
| 298 | } |
| 299 | |
| 300 | function escapeHtml(text: string): string { |
| 301 | return text |
| 302 | .replace(/&/g, "&") |
| 303 | .replace(/</g, "<") |
| 304 | .replace(/>/g, ">") |
| 305 | .replace(/"/g, """); |
| 306 | } |
| 307 | |
| 308 | export default subscribe; |