A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Check existing subs and offer to unsubscribe #37

merged opened by heaths.dev targeting main from heaths.dev/sequoia: unsubscribe
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:tg3tb5wukiml4xmxml6qm637/sh.tangled.repo.pull/3mfvxonem6y22
+155 -64
Diff #0
+103 -15
docs/src/routes/subscribe.ts
··· 154 154 155 155 subscribe.get("/", async (c) => { 156 156 const publicationUri = c.req.query("publicationUri"); 157 + const action = c.req.query("action"); 158 + const wantsJson = c.req.header("accept")?.includes("application/json"); 159 + 160 + // JSON path: subscription status check for the web component. 161 + if (wantsJson) { 162 + if (action && action !== "unsubscribe") { 163 + return c.json({ error: `Unsupported action: ${action}` }, 400); 164 + } 165 + if (!publicationUri || !publicationUri.startsWith("at://")) { 166 + return c.json({ error: "Missing or invalid publicationUri" }, 400); 167 + } 168 + const did = getSessionDid(c); 169 + if (!did) { 170 + return c.json({ authenticated: false }, 401); 171 + } 172 + try { 173 + const client = createOAuthClient( 174 + c.env.SEQUOIA_SESSIONS, 175 + c.env.CLIENT_URL, 176 + ); 177 + const session = await client.restore(did); 178 + const agent = new Agent(session); 179 + const recordUri = await findExistingSubscription( 180 + agent, 181 + did, 182 + publicationUri, 183 + ); 184 + return recordUri 185 + ? c.json({ subscribed: true, recordUri }) 186 + : c.json({ subscribed: false }); 187 + } catch { 188 + return c.json({ authenticated: false }, 401); 189 + } 190 + } 191 + 192 + // HTML path: full-page subscribe/unsubscribe flow. 157 193 const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 158 194 195 + if (action && action !== "unsubscribe") { 196 + return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400); 197 + } 198 + 159 199 if (!publicationUri || !publicationUri.startsWith("at://")) { 160 200 return c.html( 161 201 renderError("Missing or invalid publication URI.", styleHref), ··· 172 212 173 213 const did = getSessionDid(c); 174 214 if (!did) { 175 - return c.html(renderHandleForm(publicationUri, styleHref, returnTo)); 215 + return c.html( 216 + renderHandleForm(publicationUri, styleHref, returnTo, undefined, action), 217 + ); 176 218 } 177 219 178 220 try { ··· 180 222 const session = await client.restore(did); 181 223 const agent = new Agent(session); 182 224 225 + if (action === "unsubscribe") { 226 + const existingUri = await findExistingSubscription( 227 + agent, 228 + did, 229 + publicationUri, 230 + ); 231 + if (existingUri) { 232 + const rkey = existingUri.split("/").pop()!; 233 + await agent.com.atproto.repo.deleteRecord({ 234 + repo: did, 235 + collection: COLLECTION, 236 + rkey, 237 + }); 238 + } 239 + return c.html( 240 + renderSuccess( 241 + publicationUri, 242 + null, 243 + "Unsubscribed ✓", 244 + existingUri 245 + ? "You've successfully unsubscribed!" 246 + : "You weren't subscribed to this publication.", 247 + styleHref, 248 + returnTo, 249 + ), 250 + ); 251 + } 252 + 183 253 const existingUri = await findExistingSubscription( 184 254 agent, 185 255 did, ··· 187 257 ); 188 258 if (existingUri) { 189 259 return c.html( 190 - renderSuccess(publicationUri, existingUri, true, styleHref, returnTo), 260 + renderSuccess( 261 + publicationUri, 262 + existingUri, 263 + "Subscribed ✓", 264 + "You're already subscribed to this publication.", 265 + styleHref, 266 + returnTo, 267 + ), 191 268 ); 192 269 } 193 270 ··· 204 281 renderSuccess( 205 282 publicationUri, 206 283 result.data.uri, 207 - false, 284 + "Subscribed ✓", 285 + "You've successfully subscribed!", 208 286 styleHref, 209 287 returnTo, 210 288 ), ··· 218 296 styleHref, 219 297 returnTo, 220 298 "Session expired. Please sign in again.", 299 + action, 221 300 ), 222 301 ); 223 302 } ··· 235 314 const handle = (body["handle"] as string | undefined)?.trim(); 236 315 const publicationUri = body["publicationUri"] as string | undefined; 237 316 const formReturnTo = (body["returnTo"] as string | undefined) || undefined; 317 + const formAction = (body["action"] as string | undefined) || undefined; 238 318 239 319 if (!handle || !publicationUri) { 240 320 const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); ··· 246 326 247 327 const returnTo = 248 328 `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + 329 + (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + 249 330 (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); 250 331 setReturnToCookie(c, returnTo, c.env.CLIENT_URL); 251 332 ··· 263 344 styleHref: string, 264 345 returnTo?: string, 265 346 error?: string, 347 + action?: string, 266 348 ): string { 267 349 const errorHtml = error 268 350 ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` ··· 270 352 const returnToInput = returnTo 271 353 ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` 272 354 : ""; 355 + const actionInput = action 356 + ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` 357 + : ""; 273 358 274 359 return page( 275 360 ` ··· 279 364 <form method="POST" action="/subscribe/login"> 280 365 <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> 281 366 ${returnToInput} 367 + ${actionInput} 282 368 <input 283 369 type="text" 284 370 name="handle" ··· 296 382 297 383 function renderSuccess( 298 384 publicationUri: string, 299 - recordUri: string, 300 - existing: boolean, 385 + recordUri: string | null, 386 + heading: string, 387 + msg: string, 301 388 styleHref: string, 302 389 returnTo?: string, 303 390 ): string { 304 - const msg = existing 305 - ? "You're already subscribed to this publication." 306 - : "You've successfully subscribed!"; 307 391 const escapedPublicationUri = escapeHtml(publicationUri); 308 - const escapedRecordUri = escapeHtml(recordUri); 392 + const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; 309 393 310 394 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> 395 + ? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> 312 396 <script> 313 397 (function(){ 314 398 var secs = ${REDIRECT_DELAY_SECONDS}; ··· 322 406 </script>` 323 407 : ""; 324 408 const headExtra = returnTo 325 - ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapeHtml(returnTo)}" />` 409 + ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` 326 410 : ""; 327 411 328 412 return page( 329 413 ` 330 - <h1 class="vocs_H1 vocs_Heading">Subscribed ✓</h1> 414 + <h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1> 331 415 <p class="vocs_Paragraph">${msg}</p> 332 416 ${redirectHtml} 333 417 <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;"> ··· 339 423 <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> 340 424 </td> 341 425 </tr> 342 - <tr class="vocs_TableRow"> 426 + ${ 427 + recordUri 428 + ? `<tr class="vocs_TableRow"> 343 429 <td class="vocs_TableCell">Record</td> 344 430 <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> 431 + <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> 346 432 </td> 347 - </tr> 433 + </tr>` 434 + : "" 435 + } 348 436 </tbody> 349 437 </table> 350 438 `,
+52 -49
packages/cli/src/components/sequoia-subscribe.js
··· 79 79 flex-shrink: 0; 80 80 } 81 81 82 - .sequoia-subscribe-button--success { 83 - background: #16a34a; 84 - } 85 - 86 - .sequoia-subscribe-button--success:hover:not(:disabled) { 87 - background: color-mix(in srgb, #16a34a 85%, black); 88 - } 89 - 90 82 .sequoia-loading-spinner { 91 83 display: inline-block; 92 84 width: 1rem; ··· 116 108 117 109 const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 118 110 <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 119 - </svg>`; 120 - 121 - const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 122 - <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> 123 111 </svg>`; 124 112 125 113 // ============================================================================ ··· 178 166 wrapper.part = "container"; 179 167 180 168 this.wrapper = wrapper; 169 + this.subscribed = false; 181 170 this.state = { type: "idle" }; 182 171 this.abortController = null; 183 172 this.render(); ··· 188 177 } 189 178 190 179 connectedCallback() { 191 - // Pre-check publication availability so hide="auto" can take effect 192 - if (!this.publicationUri) { 193 - this.checkPublication(); 194 - } 180 + this.checkPublication(); 195 181 } 196 182 197 183 disconnectedCallback() { ··· 199 185 } 200 186 201 187 attributeChangedCallback() { 202 - // Reset to idle if attributes change after an error or success 203 - if ( 204 - this.state.type === "error" || 205 - this.state.type === "subscribed" || 206 - this.state.type === "no-publication" 207 - ) { 188 + if (this.state.type === "error" || this.state.type === "no-publication") { 208 189 this.state = { type: "idle" }; 209 190 } 210 191 this.render(); ··· 232 213 this.abortController = new AbortController(); 233 214 234 215 try { 235 - await fetchPublicationUri(); 216 + const uri = this.publicationUri ?? (await fetchPublicationUri()); 217 + this.checkSubscription(uri); 236 218 } catch { 237 219 this.state = { type: "no-publication" }; 238 220 this.render(); 239 221 } 240 222 } 241 223 224 + async checkSubscription(publicationUri) { 225 + try { 226 + const res = await fetch( 227 + `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}`, 228 + { 229 + headers: { Accept: "application/json" }, 230 + credentials: "include", 231 + }, 232 + ); 233 + if (!res.ok) return; 234 + const data = await res.json(); 235 + if (data.subscribed) { 236 + this.subscribed = true; 237 + this.render(); 238 + } 239 + } catch { 240 + // Ignore errors — show default subscribe button 241 + } 242 + } 243 + 242 244 async handleClick() { 243 - if (this.state.type === "loading" || this.state.type === "subscribed") { 245 + if (this.state.type === "loading") { 246 + return; 247 + } 248 + 249 + // Unsubscribe: redirect to full-page unsubscribe flow 250 + if (this.subscribed) { 251 + const publicationUri = 252 + this.publicationUri ?? (await fetchPublicationUri()); 253 + window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`; 244 254 return; 245 255 } 246 256 ··· 251 261 const publicationUri = 252 262 this.publicationUri ?? (await fetchPublicationUri()); 253 263 254 - // POST to the callbackUri (e.g. https://sequoia.pub/subscribe). 255 - // If the server reports the user isn't authenticated it returns a 256 - // subscribeUrl for the full-page OAuth + subscription flow. 257 264 const response = await fetch(this.callbackUri, { 258 265 method: "POST", 259 266 headers: { "Content-Type": "application/json" }, 260 267 credentials: "include", 268 + referrerPolicy: "no-referrer-when-downgrade", 261 269 body: JSON.stringify({ publicationUri }), 262 270 }); 263 271 264 272 const data = await response.json(); 265 273 266 274 if (response.status === 401 && data.authenticated === false) { 267 - // Redirect to the hosted subscribe page to complete OAuth 268 - window.location.href = data.subscribeUrl; 275 + // Redirect to the hosted subscribe page to complete OAuth, 276 + // passing the current page URL (without credentials) as returnTo. 277 + const subscribeUrl = new URL(data.subscribeUrl); 278 + const pageUrl = new URL(window.location.href); 279 + pageUrl.username = ""; 280 + pageUrl.password = ""; 281 + subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); 282 + window.location.href = subscribeUrl.toString(); 269 283 return; 270 284 } 271 285 ··· 274 288 } 275 289 276 290 const { recordUri } = data; 277 - this.state = { type: "subscribed", recordUri, publicationUri }; 291 + this.subscribed = true; 292 + this.state = { type: "idle" }; 278 293 this.render(); 279 294 280 295 this.dispatchEvent( ··· 285 300 }), 286 301 ); 287 302 } catch (error) { 288 - // Don't overwrite state if we already navigated away 289 303 if (this.state.type !== "loading") return; 290 304 291 305 const message = ··· 315 329 } 316 330 317 331 const isLoading = type === "loading"; 318 - const isSubscribed = type === "subscribed"; 319 332 320 333 const icon = isLoading 321 334 ? `<span class="sequoia-loading-spinner"></span>` 322 - : isSubscribed 323 - ? CHECK_ICON 324 - : BLUESKY_ICON; 335 + : BLUESKY_ICON; 325 336 326 - const label = isSubscribed ? "Subscribed" : this.label; 327 - const buttonClass = [ 328 - "sequoia-subscribe-button", 329 - isSubscribed ? "sequoia-subscribe-button--success" : "", 330 - ] 331 - .filter(Boolean) 332 - .join(" "); 337 + const label = this.subscribed ? "Unsubscribe on Bluesky" : this.label; 333 338 334 339 const errorHtml = 335 340 type === "error" ··· 338 343 339 344 this.wrapper.innerHTML = ` 340 345 <button 341 - class="${buttonClass}" 346 + class="sequoia-subscribe-button" 342 347 type="button" 343 348 part="button" 344 - ${isLoading || isSubscribed ? "disabled" : ""} 345 - aria-label="${isSubscribed ? "Subscribed" : this.label}" 349 + ${isLoading ? "disabled" : ""} 350 + aria-label="${label}" 346 351 > 347 352 ${icon} 348 353 ${label} ··· 350 355 ${errorHtml} 351 356 `; 352 357 353 - if (type !== "subscribed") { 354 - const btn = this.wrapper.querySelector("button"); 355 - btn?.addEventListener("click", () => this.handleClick()); 356 - } 358 + const btn = this.wrapper.querySelector("button"); 359 + btn?.addEventListener("click", () => this.handleClick()); 357 360 } 358 361 } 359 362

History

3 rounds 10 comments
sign up or login to add to the discussion
3 commits
expand
Check existing subs and offer to unsubscribe
Add separate /subscribe/check route
Pass DID through query parameter
expand 2 comments

Not sure if storing it in localStorage is worth it assuming document.cookie always works. This is what Opus 4.6 wanted to do so maybe there's some good reason? If not, we could certainly clean up the code some.

Tested it with reverse-proxied sites and works well. Record gets deleted as expected and the button changes state as expected.

Awesome!! Thanks for this work; sick feature :)

pull request successfully merged
2 commits
expand
Check existing subs and offer to unsubscribe
Add separate /subscribe/check route
expand 7 comments

Made a separate route to check credentials. Cleaner and doesn't radically alter the behavior of the existing route. Note, however, that this doesn't work. Seems CORS is preventing it despite setting SameSite=None; Secure on the session_id cookie, which seems like it'd be safe in this case.

Using both curl and the ngrok traffic inspector (reverse-proxied for https endpoints as needed) confirm the right CORS headers are being sent from the Api endpoint, and I made sure to change my cookie settings for that endpoint but the cookie just isn't being sent it seems. I had some console.log statements in there which confirmed the DID wasn't found i.e., no session_id cookie.

I think the only reasonable way this could work is to host the script on the same origin as the /oauth and /subscribe APIs, which means either self-hosting (separate issue) or centralized hosting via sequoia.pub or at least a CDN.

I might have a solution that's not perfect, but I guess better than nothing. I realized that we could probably fetch this state client side since we are storing DID as part of the session cookie. However since we can't generally access those specific cookies, we can still store the state in local storage. An approach I took was to use the query params to store the DID as well as the subscribe state. With the DID we can make a client side call to the PDS for the site.standard.graph.subscription record, and if it matches the publication of the current site, then we show the unsubscribe button.

The downside is that since we're using local storage, clearing browser data or using another device means it will show the subscribe button, and worse cast scenario they try to create a record that already exists. I did try it out and it does work, so if you're interested I've created a diff patch file that can be applied to your branch:

curl -o unsubscribe.patch https://files.stevedylan.dev/unsubscribe.patch

git apply unsubscribe.patch

(git patches slap and I'm glad I messed around with dwm in linux to learn them lol)

Feel free to take it or leave it! Also we will need to update the OAuth scopes again to include the delete functionality for unsubscribing.

scope: "atproto repo:site.standard.graph.subscription?action=create&action=delete",

I would commit directly with the updates but since you're currently working off a fork I thought that might get messy. To that end I've added you as a collaborator on Tangled for Sequoia, so next time you need to make a PR feel free to make a branch directly on the repo and push that way :)

Thanks! I'll take a look at this tomorrow. Good call on the scopes: totally forgot about that.

The local storage idea came up when I was looking for solutions as well. That said, it at least roams with online profiles like Apple IDs, Chrome and Edge profiles, etc., right? I think so. So not terrible but, yeah, there are holes. not any worse than not having it, though. At least the flow tells you you're already sub'd so it doesn't hurt anything.

Was there a reason you deleted the /subscribe/check route? Don't we still need that for just the check, or is that the part that can't possibly work anyway? Given that, the routing to return to the original post actually was already working so I don't think modifying the URL is necessary further.

Or was the point to send those back to the publication itself for the sequoia-subscribe.js component to store in local storage?

I made that change - actually store it in a cookie via JS - and it works. New update incoming.

I think I deleted it since we switched to the client side call, but I was under the impression that was the only use case. Totally fine keeping it if we need it / want to use it later!

heaths.dev submitted #0
2 commits
expand
Send referrer path and origin through login flow
Check existing subs and offer to unsubscribe
expand 1 comment

This includes the other PR only because I needed some of the changes therein to make the diff less messy. That other PR should be taken first then this one. Even if we make other changes to the other PR, this should keep this PR more easily mergable.