this repo has no description
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, "&")
412 .replace(/</g, "<")
413 .replace(/>/g, ">")
414 .replace(/"/g, """);
415}
416
417export default subscribe;