packages/server/src/routes/subscribe.ts 10.8 K raw
1
import { Agent } from "@atproto/api";
2
import { Hono } from "hono";
3
import type { Database } from "bun:sqlite";
4
import { createOAuthClient } from "../lib/oauth-client";
5
import { getSessionDid, setReturnToCookie } from "../lib/session";
6
import { page, escapeHtml } from "../lib/theme";
7
import type { Env } from "../env";
8
9
type Variables = { env: Env; db: Database };
10
11
const subscribe = new Hono<{ Variables: Variables }>();
12
13
const COLLECTION = "site.standard.graph.subscription";
14
const REDIRECT_DELAY_SECONDS = 5;
15
16
// ============================================================================
17
// Helpers
18
// ============================================================================
19
20
function 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
35
async 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
67
subscribe.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
132
subscribe.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
257
subscribe.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
293
subscribe.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
321
function 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
356
function 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
415
function renderError(message: string): string {
416
	return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`);
417
}
418
419
export default subscribe;