docs/src/routes/subscribe.ts 15.6 K raw
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(
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
35
const subscribe = new Hono<{ Bindings: Env }>();
36
37
const COLLECTION = "site.standard.graph.subscription";
38
const REDIRECT_DELAY_SECONDS = 5;
39
40
// ============================================================================
41
// Helpers
42
// ============================================================================
43
44
/**
45
 * Append a query parameter to a returnTo URL, preserving existing params.
46
 */
47
function withReturnToParam(
48
	returnTo: string | undefined,
49
	key: string,
50
	value: string,
51
): string | undefined {
52
	if (!returnTo) return undefined;
53
	try {
54
		const url = new URL(returnTo);
55
		url.searchParams.set(key, value);
56
		return url.toString();
57
	} catch {
58
		return returnTo;
59
	}
60
}
61
62
/**
63
 * Scan the user's repo for an existing site.standard.graph.subscription
64
 * matching the given publication URI. Returns the record AT-URI if found.
65
 */
66
async function findExistingSubscription(
67
	agent: Agent,
68
	did: string,
69
	publicationUri: string,
70
): Promise<string | null> {
71
	let cursor: string | undefined;
72
73
	do {
74
		const result = await agent.com.atproto.repo.listRecords({
75
			repo: did,
76
			collection: COLLECTION,
77
			limit: 100,
78
			cursor,
79
		});
80
81
		for (const record of result.data.records) {
82
			const value = record.value as { publication?: string };
83
			if (value.publication === publicationUri) {
84
				return record.uri;
85
			}
86
		}
87
88
		cursor = result.data.cursor;
89
	} while (cursor);
90
91
	return null;
92
}
93
94
// ============================================================================
95
// POST /subscribe
96
//
97
// Called via fetch() from the sequoia-subscribe web component.
98
// Body JSON: { publicationUri: string }
99
//
100
// Responses:
101
//   200 { subscribed: true, existing: boolean, recordUri: string }
102
//   400 { error: string }
103
//   401 { authenticated: false, subscribeUrl: string }
104
// ============================================================================
105
106
subscribe.post("/", async (c) => {
107
	let publicationUri: string;
108
	try {
109
		const body = await c.req.json<{ publicationUri?: string }>();
110
		publicationUri = body.publicationUri ?? "";
111
	} catch {
112
		return c.json({ error: "Invalid JSON body" }, 400);
113
	}
114
115
	if (!publicationUri || !publicationUri.startsWith("at://")) {
116
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
117
	}
118
119
	const did = getSessionDid(c);
120
	if (!did) {
121
		const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
122
		return c.json({ authenticated: false, subscribeUrl }, 401);
123
	}
124
125
	try {
126
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
127
		const session = await client.restore(did);
128
		const agent = new Agent(session);
129
130
		const existingUri = await findExistingSubscription(
131
			agent,
132
			did,
133
			publicationUri,
134
		);
135
		if (existingUri) {
136
			return c.json({
137
				subscribed: true,
138
				existing: true,
139
				recordUri: existingUri,
140
			});
141
		}
142
143
		const result = await agent.com.atproto.repo.createRecord({
144
			repo: did,
145
			collection: COLLECTION,
146
			record: {
147
				$type: COLLECTION,
148
				publication: publicationUri,
149
			},
150
		});
151
152
		return c.json({
153
			subscribed: true,
154
			existing: false,
155
			recordUri: result.data.uri,
156
		});
157
	} catch (error) {
158
		console.error("Subscribe POST error:", error);
159
		// Treat expired/missing session as unauthenticated
160
		const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
161
		return c.json({ authenticated: false, subscribeUrl }, 401);
162
	}
163
});
164
165
// ============================================================================
166
// GET /subscribe?publicationUri=at://...
167
//
168
// Full-page OAuth + subscription flow. Unauthenticated users land here after
169
// the component redirects them, and authenticated users land here after the
170
// OAuth callback (via the login_return_to cookie set in POST /subscribe/login).
171
// ============================================================================
172
173
subscribe.get("/", async (c) => {
174
	const publicationUri = c.req.query("publicationUri");
175
	const action = c.req.query("action");
176
	const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
177
178
	if (action && action !== "unsubscribe") {
179
		return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
180
	}
181
182
	if (!publicationUri || !publicationUri.startsWith("at://")) {
183
		return c.html(
184
			renderError("Missing or invalid publication URI.", styleHref),
185
			400,
186
		);
187
	}
188
189
	// Prefer an explicit returnTo query param (survives the OAuth round-trip);
190
	// fall back to the Referer header on the first visit, ignoring self-referrals.
191
	const referer = c.req.header("referer");
192
	const returnTo =
193
		c.req.query("returnTo") ??
194
		(referer && !referer.includes("/subscribe") ? referer : undefined);
195
196
	const did = getSessionDid(c);
197
	if (!did) {
198
		return c.html(
199
			renderHandleForm(publicationUri, styleHref, returnTo, undefined, action),
200
		);
201
	}
202
203
	try {
204
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
205
		const session = await client.restore(did);
206
		const agent = new Agent(session);
207
208
		if (action === "unsubscribe") {
209
			const existingUri = await findExistingSubscription(
210
				agent,
211
				did,
212
				publicationUri,
213
			);
214
			if (existingUri) {
215
				const rkey = existingUri.split("/").pop()!;
216
				await agent.com.atproto.repo.deleteRecord({
217
					repo: did,
218
					collection: COLLECTION,
219
					rkey,
220
				});
221
			}
222
223
			// Strip sequoia_did from returnTo so the component doesn't re-store it
224
			let cleanReturnTo = returnTo;
225
			if (cleanReturnTo) {
226
				try {
227
					const rtUrl = new URL(cleanReturnTo);
228
					rtUrl.searchParams.delete("sequoia_did");
229
					cleanReturnTo = rtUrl.toString();
230
				} catch {
231
					// keep as-is
232
				}
233
			}
234
235
			return c.html(
236
				renderSuccess(
237
					publicationUri,
238
					null,
239
					"Unsubscribed ✓",
240
					existingUri
241
						? "You've successfully unsubscribed!"
242
						: "You weren't subscribed to this publication.",
243
					styleHref,
244
					withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"),
245
				),
246
			);
247
		}
248
249
		const existingUri = await findExistingSubscription(
250
			agent,
251
			did,
252
			publicationUri,
253
		);
254
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
255
256
		if (existingUri) {
257
			return c.html(
258
				renderSuccess(
259
					publicationUri,
260
					existingUri,
261
					"Subscribed ✓",
262
					"You're already subscribed to this publication.",
263
					styleHref,
264
					returnToWithDid,
265
				),
266
			);
267
		}
268
269
		const result = await agent.com.atproto.repo.createRecord({
270
			repo: did,
271
			collection: COLLECTION,
272
			record: {
273
				$type: COLLECTION,
274
				publication: publicationUri,
275
			},
276
		});
277
278
		return c.html(
279
			renderSuccess(
280
				publicationUri,
281
				result.data.uri,
282
				"Subscribed ✓",
283
				"You've successfully subscribed!",
284
				styleHref,
285
				returnToWithDid,
286
			),
287
		);
288
	} catch (error) {
289
		console.error("Subscribe GET error:", error);
290
		// Session expired - ask the user to sign in again
291
		return c.html(
292
			renderHandleForm(
293
				publicationUri,
294
				styleHref,
295
				returnTo,
296
				"Session expired. Please sign in again.",
297
				action,
298
			),
299
		);
300
	}
301
});
302
303
// ============================================================================
304
// GET /subscribe/check?publicationUri=at://...
305
//
306
// JSON-only endpoint for the web component to check subscription status.
307
//
308
// Responses:
309
//   200 { subscribed: true, recordUri: string }
310
//   200 { subscribed: false }
311
//   400 { error: string }
312
//   401 { authenticated: false }
313
// ============================================================================
314
315
subscribe.get("/check", async (c) => {
316
	const publicationUri = c.req.query("publicationUri");
317
318
	if (!publicationUri || !publicationUri.startsWith("at://")) {
319
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
320
	}
321
322
	// Prefer the server-side session DID; fall back to a client-provided DID
323
	// (stored by the web component from a previous subscribe flow).
324
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
325
	if (!did || !did.startsWith("did:")) {
326
		return c.json({ authenticated: false }, 401);
327
	}
328
329
	try {
330
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
331
		const session = await client.restore(did);
332
		const agent = new Agent(session);
333
		const recordUri = await findExistingSubscription(
334
			agent,
335
			did,
336
			publicationUri,
337
		);
338
		return recordUri
339
			? c.json({ subscribed: true, recordUri })
340
			: c.json({ subscribed: false });
341
	} catch {
342
		return c.json({ authenticated: false }, 401);
343
	}
344
});
345
346
// ============================================================================
347
// POST /subscribe/login
348
//
349
// Handles the handle-entry form submission. Stores the return URL in a cookie
350
// so the OAuth callback in auth.ts can redirect back to /subscribe after auth.
351
// ============================================================================
352
353
subscribe.post("/login", async (c) => {
354
	const body = await c.req.parseBody();
355
	const handle = (body["handle"] as string | undefined)?.trim();
356
	const publicationUri = body["publicationUri"] as string | undefined;
357
	const formReturnTo = (body["returnTo"] as string | undefined) || undefined;
358
	const formAction = (body["action"] as string | undefined) || undefined;
359
360
	if (!handle || !publicationUri) {
361
		const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
362
		return c.html(
363
			renderError("Missing handle or publication URI.", styleHref),
364
			400,
365
		);
366
	}
367
368
	const returnTo =
369
		`${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` +
370
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
371
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
372
	setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
373
374
	return c.redirect(
375
		`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
376
	);
377
});
378
379
// ============================================================================
380
// HTML rendering
381
// ============================================================================
382
383
function renderHandleForm(
384
	publicationUri: string,
385
	styleHref: string,
386
	returnTo?: string,
387
	error?: string,
388
	action?: string,
389
): string {
390
	const errorHtml = error
391
		? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
392
		: "";
393
	const returnToInput = returnTo
394
		? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
395
		: "";
396
	const actionInput = action
397
		? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
398
		: "";
399
400
	return page(
401
		`
402
		<h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1>
403
		<p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p>
404
		${errorHtml}
405
		<form method="POST" action="/subscribe/login">
406
			<input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
407
			${returnToInput}
408
			${actionInput}
409
			<input
410
				type="text"
411
				name="handle"
412
				placeholder="you.bsky.social"
413
				autocomplete="username"
414
				required
415
				autofocus
416
			/>
417
			<button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button>
418
		</form>
419
	`,
420
		styleHref,
421
	);
422
}
423
424
function renderSuccess(
425
	publicationUri: string,
426
	recordUri: string | null,
427
	heading: string,
428
	msg: string,
429
	styleHref: string,
430
	returnTo?: string,
431
): string {
432
	const escapedPublicationUri = escapeHtml(publicationUri);
433
	const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
434
435
	const redirectHtml = returnTo
436
		? `<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>
437
		<script>
438
		(function(){
439
			var secs = ${REDIRECT_DELAY_SECONDS};
440
			var el = document.getElementById('countdown');
441
			var iv = setInterval(function(){
442
				secs--;
443
				if (el) el.textContent = String(secs);
444
				if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
445
			}, 1000);
446
		})();
447
		</script>`
448
		: "";
449
	const headExtra = returnTo
450
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
451
		: "";
452
453
	return page(
454
		`
455
		<h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1>
456
		<p class="vocs_Paragraph">${msg}</p>
457
		${redirectHtml}
458
		<table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;">
459
			<colgroup><col style="width:7rem;"><col></colgroup>
460
			<tbody>
461
				<tr class="vocs_TableRow">
462
					<td class="vocs_TableCell">Publication</td>
463
					<td class="vocs_TableCell" style="overflow:hidden;">
464
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div>
465
					</td>
466
				</tr>
467
				${
468
					recordUri
469
						? `<tr class="vocs_TableRow">
470
					<td class="vocs_TableCell">Record</td>
471
					<td class="vocs_TableCell" style="overflow:hidden;">
472
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
473
					</td>
474
				</tr>`
475
						: ""
476
				}
477
			</tbody>
478
		</table>
479
	`,
480
		styleHref,
481
		headExtra,
482
	);
483
}
484
485
function renderError(message: string, styleHref: string): string {
486
	return page(
487
		`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`,
488
		styleHref,
489
	);
490
}
491
492
function page(body: string, styleHref: string, headExtra = ""): string {
493
	return `<!DOCTYPE html>
494
<html lang="en">
495
<head>
496
  <meta charset="UTF-8" />
497
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
498
  <title>Sequoia · Subscribe</title>
499
  <link rel="stylesheet" href="${styleHref}" />
500
  <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script>
501
  ${headExtra}
502
  <style>
503
    .page-container {
504
      max-width: calc(var(--vocs-content_width, 480px) / 1.6);
505
      margin: 4rem auto;
506
      padding: 0 var(--vocs-space_20, 1.25rem);
507
    }
508
    .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); }
509
    .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); }
510
    input[type="text"] {
511
      padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem);
512
      border: 1px solid var(--vocs-color_border, #D5D1C8);
513
      border-radius: var(--vocs-borderRadius_6, 6px);
514
      margin-bottom: var(--vocs-space_20, 1.25rem);
515
	  min-width: 30vh;
516
	  width: 100%;
517
      font-size: var(--vocs-fontSize_16, 1rem);
518
      font-family: inherit;
519
      background: var(--vocs-color_background, #F5F3EF);
520
      color: var(--vocs-color_text, #2C2C2C);
521
    }
522
    input[type="text"]:focus {
523
      border-color: var(--vocs-color_borderAccent, #3A5A40);
524
      outline: 2px solid var(--vocs-color_borderAccent, #3A5A40);
525
      outline-offset: 2px;
526
    }
527
    .error { color: var(--vocs-color_dangerText, #8B3A3A); }
528
  </style>
529
</head>
530
<body>
531
  <div class="page-container">
532
    ${body}
533
  </div>
534
</body>
535
</html>`;
536
}
537
538
function escapeHtml(text: string): string {
539
	return text
540
		.replace(/&/g, "&amp;")
541
		.replace(/</g, "&lt;")
542
		.replace(/>/g, "&gt;")
543
		.replace(/"/g, "&quot;");
544
}
545
546
export default subscribe;