A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 419 lines 11 kB view raw
1import { Agent } from "@atproto/api"; 2import { Hono } from "hono"; 3import type { Database } from "bun:sqlite"; 4import { createOAuthClient } from "../lib/oauth-client"; 5import { getSessionDid, setReturnToCookie } from "../lib/session"; 6import { page, escapeHtml } from "../lib/theme"; 7import type { Env } from "../env"; 8 9type Variables = { env: Env; db: Database }; 10 11const subscribe = new Hono<{ Variables: Variables }>(); 12 13const COLLECTION = "site.standard.graph.subscription"; 14const REDIRECT_DELAY_SECONDS = 5; 15 16// ============================================================================ 17// Helpers 18// ============================================================================ 19 20function withReturnToParam( 21 returnTo: string | undefined, 22 key: string, 23 value: string, 24): string | undefined { 25 if (!returnTo) return undefined; 26 try { 27 const url = new URL(returnTo); 28 url.searchParams.set(key, value); 29 return url.toString(); 30 } catch { 31 return returnTo; 32 } 33} 34 35async function findExistingSubscription( 36 agent: Agent, 37 did: string, 38 publicationUri: string, 39): Promise<string | null> { 40 let cursor: string | undefined; 41 42 do { 43 const result = await agent.com.atproto.repo.listRecords({ 44 repo: did, 45 collection: COLLECTION, 46 limit: 100, 47 cursor, 48 }); 49 50 for (const record of result.data.records) { 51 const value = record.value as { publication?: string }; 52 if (value.publication === publicationUri) { 53 return record.uri; 54 } 55 } 56 57 cursor = result.data.cursor; 58 } while (cursor); 59 60 return null; 61} 62 63// ============================================================================ 64// POST /subscribe 65// ============================================================================ 66 67subscribe.post("/", async (c) => { 68 const env = c.get("env"); 69 const db = c.get("db"); 70 71 let publicationUri: string; 72 try { 73 const body = await c.req.json<{ publicationUri?: string }>(); 74 publicationUri = body.publicationUri ?? ""; 75 } catch { 76 return c.json({ error: "Invalid JSON body" }, 400); 77 } 78 79 if (!publicationUri || !publicationUri.startsWith("at://")) { 80 return c.json({ error: "Missing or invalid publicationUri" }, 400); 81 } 82 83 const did = getSessionDid(c); 84 if (!did) { 85 const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 86 return c.json({ authenticated: false, subscribeUrl }, 401); 87 } 88 89 try { 90 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 91 const session = await client.restore(did); 92 const agent = new Agent(session); 93 94 const existingUri = await findExistingSubscription( 95 agent, 96 did, 97 publicationUri, 98 ); 99 if (existingUri) { 100 return c.json({ 101 subscribed: true, 102 existing: true, 103 recordUri: existingUri, 104 }); 105 } 106 107 const result = await agent.com.atproto.repo.createRecord({ 108 repo: did, 109 collection: COLLECTION, 110 record: { 111 $type: COLLECTION, 112 publication: publicationUri, 113 }, 114 }); 115 116 return c.json({ 117 subscribed: true, 118 existing: false, 119 recordUri: result.data.uri, 120 }); 121 } catch (error) { 122 console.error("Subscribe POST error:", error); 123 const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 124 return c.json({ authenticated: false, subscribeUrl }, 401); 125 } 126}); 127 128// ============================================================================ 129// GET /subscribe 130// ============================================================================ 131 132subscribe.get("/", async (c) => { 133 const env = c.get("env"); 134 const db = c.get("db"); 135 136 const publicationUri = c.req.query("publicationUri"); 137 const action = c.req.query("action"); 138 139 if (action && action !== "unsubscribe") { 140 return c.html(renderError(`Unsupported action: ${action}`), 400); 141 } 142 143 if (!publicationUri || !publicationUri.startsWith("at://")) { 144 return c.html(renderError("Missing or invalid publication URI."), 400); 145 } 146 147 const referer = c.req.header("referer"); 148 const returnTo = 149 c.req.query("returnTo") ?? 150 (referer && !referer.includes("/subscribe") ? referer : undefined); 151 152 const did = getSessionDid(c); 153 if (!did) { 154 return c.html( 155 renderHandleForm(publicationUri, returnTo, undefined, action), 156 ); 157 } 158 159 try { 160 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 161 const session = await client.restore(did); 162 const agent = new Agent(session); 163 164 if (action === "unsubscribe") { 165 const existingUri = await findExistingSubscription( 166 agent, 167 did, 168 publicationUri, 169 ); 170 if (existingUri) { 171 const rkey = existingUri.split("/").pop()!; 172 await agent.com.atproto.repo.deleteRecord({ 173 repo: did, 174 collection: COLLECTION, 175 rkey, 176 }); 177 } 178 179 let cleanReturnTo = returnTo; 180 if (cleanReturnTo) { 181 try { 182 const rtUrl = new URL(cleanReturnTo); 183 rtUrl.searchParams.delete("sequoia_did"); 184 cleanReturnTo = rtUrl.toString(); 185 } catch { 186 // keep as-is 187 } 188 } 189 190 return c.html( 191 renderSuccess( 192 publicationUri, 193 null, 194 "Unsubscribed", 195 existingUri 196 ? "You've successfully unsubscribed!" 197 : "You weren't subscribed to this publication.", 198 withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), 199 ), 200 ); 201 } 202 203 const existingUri = await findExistingSubscription( 204 agent, 205 did, 206 publicationUri, 207 ); 208 const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); 209 210 if (existingUri) { 211 return c.html( 212 renderSuccess( 213 publicationUri, 214 existingUri, 215 "Subscribed", 216 "You're already subscribed to this publication.", 217 returnToWithDid, 218 ), 219 ); 220 } 221 222 const result = await agent.com.atproto.repo.createRecord({ 223 repo: did, 224 collection: COLLECTION, 225 record: { 226 $type: COLLECTION, 227 publication: publicationUri, 228 }, 229 }); 230 231 return c.html( 232 renderSuccess( 233 publicationUri, 234 result.data.uri, 235 "Subscribed", 236 "You've successfully subscribed!", 237 returnToWithDid, 238 ), 239 ); 240 } catch (error) { 241 console.error("Subscribe GET error:", error); 242 return c.html( 243 renderHandleForm( 244 publicationUri, 245 returnTo, 246 "Session expired. Please sign in again.", 247 action, 248 ), 249 ); 250 } 251}); 252 253// ============================================================================ 254// GET /subscribe/check 255// ============================================================================ 256 257subscribe.get("/check", async (c) => { 258 const env = c.get("env"); 259 const db = c.get("db"); 260 261 const publicationUri = c.req.query("publicationUri"); 262 263 if (!publicationUri || !publicationUri.startsWith("at://")) { 264 return c.json({ error: "Missing or invalid publicationUri" }, 400); 265 } 266 267 const did = getSessionDid(c) ?? c.req.query("did") ?? null; 268 if (!did || !did.startsWith("did:")) { 269 return c.json({ authenticated: false }, 401); 270 } 271 272 try { 273 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 274 const session = await client.restore(did); 275 const agent = new Agent(session); 276 const recordUri = await findExistingSubscription( 277 agent, 278 did, 279 publicationUri, 280 ); 281 return recordUri 282 ? c.json({ subscribed: true, recordUri }) 283 : c.json({ subscribed: false }); 284 } catch { 285 return c.json({ authenticated: false }, 401); 286 } 287}); 288 289// ============================================================================ 290// POST /subscribe/login 291// ============================================================================ 292 293subscribe.post("/login", async (c) => { 294 const env = c.get("env"); 295 296 const body = await c.req.parseBody(); 297 const handle = (body["handle"] as string | undefined)?.trim(); 298 const publicationUri = body["publicationUri"] as string | undefined; 299 const formReturnTo = (body["returnTo"] as string | undefined) || undefined; 300 const formAction = (body["action"] as string | undefined) || undefined; 301 302 if (!handle || !publicationUri) { 303 return c.html(renderError("Missing handle or publication URI."), 400); 304 } 305 306 const returnTo = 307 `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + 308 (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + 309 (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); 310 setReturnToCookie(c, returnTo, env.CLIENT_URL); 311 312 return c.redirect( 313 `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, 314 ); 315}); 316 317// ============================================================================ 318// HTML rendering 319// ============================================================================ 320 321function renderHandleForm( 322 publicationUri: string, 323 returnTo?: string, 324 error?: string, 325 action?: string, 326): string { 327 const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : ""; 328 const returnToInput = returnTo 329 ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` 330 : ""; 331 const actionInput = action 332 ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` 333 : ""; 334 335 return page(` 336 <h1>Subscribe on Bluesky</h1> 337 <p>Enter your Bluesky handle to subscribe to this publication.</p> 338 ${errorHtml} 339 <form method="POST" action="/subscribe/login"> 340 <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> 341 ${returnToInput} 342 ${actionInput} 343 <input 344 type="text" 345 name="handle" 346 placeholder="you.bsky.social" 347 autocomplete="username" 348 required 349 autofocus 350 /> 351 <button type="submit">Continue on Bluesky</button> 352 </form> 353 `); 354} 355 356function renderSuccess( 357 publicationUri: string, 358 recordUri: string | null, 359 heading: string, 360 msg: string, 361 returnTo?: string, 362): string { 363 const escapedPublicationUri = escapeHtml(publicationUri); 364 const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; 365 366 const redirectHtml = returnTo 367 ? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> 368 <script> 369 (function(){ 370 var secs = ${REDIRECT_DELAY_SECONDS}; 371 var el = document.getElementById('countdown'); 372 var iv = setInterval(function(){ 373 secs--; 374 if (el) el.textContent = String(secs); 375 if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } 376 }, 1000); 377 })(); 378 </script>` 379 : ""; 380 const headExtra = returnTo 381 ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` 382 : ""; 383 384 return page( 385 ` 386 <h1>${escapeHtml(heading)}</h1> 387 <p>${msg}</p> 388 ${redirectHtml} 389 <table> 390 <colgroup><col style="width:7rem;"><col></colgroup> 391 <tbody> 392 <tr> 393 <td>Publication</td> 394 <td> 395 <div><code><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> 396 </td> 397 </tr> 398 ${ 399 recordUri 400 ? `<tr> 401 <td>Record</td> 402 <td> 403 <div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> 404 </td> 405 </tr>` 406 : "" 407 } 408 </tbody> 409 </table> 410 `, 411 headExtra, 412 ); 413} 414 415function renderError(message: string): string { 416 return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`); 417} 418 419export default subscribe;