docs/src/routes/subscribe.ts 10.4 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
import {
6
	findExistingRecord,
7
	renderError,
8
	renderHandleForm,
9
	renderSuccess,
10
	withReturnToParam,
11
} from "./lib";
12
13
interface Env {
14
	ASSETS: Fetcher;
15
	SEQUOIA_SESSIONS: KVNamespace;
16
	CLIENT_URL: string;
17
}
18
19
// Cache the vocs-generated stylesheet href across requests (changes on rebuild).
20
let _vocsStyleHref: string | null = null;
21
22
async function getVocsStyleHref(
23
	assets: Fetcher,
24
	baseUrl: string,
25
): Promise<string> {
26
	if (_vocsStyleHref) return _vocsStyleHref;
27
	try {
28
		const indexUrl = new URL("/", baseUrl).toString();
29
		const res = await assets.fetch(indexUrl);
30
		const html = await res.text();
31
		const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/);
32
		if (match?.[1]) {
33
			_vocsStyleHref = match[1];
34
			return match[1];
35
		}
36
	} catch {
37
		// Fall back to the custom stylesheet which at least provides --sequoia-* vars
38
	}
39
	return "/styles.css";
40
}
41
42
const subscribe = new Hono<{ Bindings: Env }>();
43
44
const COLLECTION = "site.standard.graph.subscription";
45
46
// ============================================================================
47
// Helpers
48
// ============================================================================
49
50
// ============================================================================
51
// POST /subscribe
52
//
53
// Called via fetch() from the sequoia-subscribe web component.
54
// Body JSON: { publicationUri: string }
55
//
56
// Responses:
57
//   200 { subscribed: true, existing: boolean, recordUri: string }
58
//   400 { error: string }
59
//   401 { authenticated: false, subscribeUrl: string }
60
// ============================================================================
61
62
subscribe.post("/", async (c) => {
63
	let publicationUri: string;
64
	try {
65
		const body = await c.req.json<{ publicationUri?: string }>();
66
		publicationUri = body.publicationUri ?? "";
67
	} catch {
68
		return c.json({ error: "Invalid JSON body" }, 400);
69
	}
70
71
	if (!publicationUri || !publicationUri.startsWith("at://")) {
72
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
73
	}
74
75
	const did = getSessionDid(c);
76
	if (!did) {
77
		const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
78
		return c.json({ authenticated: false, subscribeUrl }, 401);
79
	}
80
81
	try {
82
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
83
		const session = await client.restore(did);
84
		const agent = new Agent(session);
85
86
		const existingUri = await findExistingRecord(
87
			agent,
88
			did,
89
			COLLECTION,
90
			"publication",
91
			publicationUri,
92
		);
93
		if (existingUri) {
94
			return c.json({
95
				subscribed: true,
96
				existing: true,
97
				recordUri: existingUri,
98
			});
99
		}
100
101
		const result = await agent.com.atproto.repo.createRecord({
102
			repo: did,
103
			collection: COLLECTION,
104
			record: {
105
				$type: COLLECTION,
106
				publication: publicationUri,
107
			},
108
		});
109
110
		return c.json({
111
			subscribed: true,
112
			existing: false,
113
			recordUri: result.data.uri,
114
		});
115
	} catch (error) {
116
		console.error("Subscribe POST error:", error);
117
		// Treat expired/missing session as unauthenticated
118
		const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
119
		return c.json({ authenticated: false, subscribeUrl }, 401);
120
	}
121
});
122
123
// ============================================================================
124
// GET /subscribe?publicationUri=at://...
125
//
126
// Full-page OAuth + subscription flow. Unauthenticated users land here after
127
// the component redirects them, and authenticated users land here after the
128
// OAuth callback (via the login_return_to cookie set in POST /subscribe/login).
129
// ============================================================================
130
131
subscribe.get("/", async (c) => {
132
	const publicationUri = c.req.query("publicationUri");
133
	const action = c.req.query("action");
134
	const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
135
136
	if (action && action !== "unsubscribe") {
137
		return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
138
	}
139
140
	if (!publicationUri || !publicationUri.startsWith("at://")) {
141
		return c.html(
142
			renderError("Missing or invalid publication URI.", styleHref),
143
			400,
144
		);
145
	}
146
147
	// Prefer an explicit returnTo query param (survives the OAuth round-trip);
148
	// fall back to the Referer header on the first visit, ignoring self-referrals.
149
	const referer = c.req.header("referer");
150
	const returnTo =
151
		c.req.query("returnTo") ??
152
		(referer && !referer.includes("/subscribe") ? referer : undefined);
153
154
	const did = getSessionDid(c);
155
	if (!did) {
156
		return c.html(
157
			renderHandleForm(
158
				{
159
					resourceUri: publicationUri,
160
					resourceField: "publicationUri",
161
					loginPath: "/subscribe/login",
162
					title: "Subscribe on Sequoia",
163
					description:
164
						"Enter your Bluesky handle to subscribe to this publication.",
165
					buttonLabel: "Continue on Bluesky",
166
					returnTo,
167
					action,
168
				},
169
				styleHref,
170
			),
171
		);
172
	}
173
174
	try {
175
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
176
		const session = await client.restore(did);
177
		const agent = new Agent(session);
178
179
		if (action === "unsubscribe") {
180
			const existingUri = await findExistingRecord(
181
				agent,
182
				did,
183
				COLLECTION,
184
				"publication",
185
				publicationUri,
186
			);
187
			if (existingUri) {
188
				const rkey = existingUri.split("/").pop()!;
189
				await agent.com.atproto.repo.deleteRecord({
190
					repo: did,
191
					collection: COLLECTION,
192
					rkey,
193
				});
194
			}
195
196
			// Strip sequoia_did from returnTo so the component doesn't re-store it
197
			let cleanReturnTo = returnTo;
198
			if (cleanReturnTo) {
199
				try {
200
					const rtUrl = new URL(cleanReturnTo);
201
					rtUrl.searchParams.delete("sequoia_did");
202
					cleanReturnTo = rtUrl.toString();
203
				} catch {
204
					// keep as-is
205
				}
206
			}
207
208
			return c.html(
209
				renderSuccess(
210
					{
211
						resourceUri: publicationUri,
212
						resourceLabel: "Publication",
213
						recordUri: null,
214
						heading: "Unsubscribed ✓",
215
						msg: existingUri
216
							? "You've successfully unsubscribed!"
217
							: "You weren't subscribed to this publication.",
218
						returnTo: withReturnToParam(
219
							cleanReturnTo,
220
							"sequoia_unsubscribed",
221
							"1",
222
						),
223
					},
224
					styleHref,
225
				),
226
			);
227
		}
228
229
		const existingUri = await findExistingRecord(
230
			agent,
231
			did,
232
			COLLECTION,
233
			"publication",
234
			publicationUri,
235
		);
236
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
237
238
		if (existingUri) {
239
			return c.html(
240
				renderSuccess(
241
					{
242
						resourceUri: publicationUri,
243
						resourceLabel: "Publication",
244
						recordUri: existingUri,
245
						heading: "Subscribed ✓",
246
						msg: "You're already subscribed to this publication.",
247
						returnTo: returnToWithDid,
248
					},
249
					styleHref,
250
				),
251
			);
252
		}
253
254
		const result = await agent.com.atproto.repo.createRecord({
255
			repo: did,
256
			collection: COLLECTION,
257
			record: {
258
				$type: COLLECTION,
259
				publication: publicationUri,
260
			},
261
		});
262
263
		return c.html(
264
			renderSuccess(
265
				{
266
					resourceUri: publicationUri,
267
					resourceLabel: "Publication",
268
					recordUri: result.data.uri,
269
					heading: "Subscribed ✓",
270
					msg: "You've successfully subscribed!",
271
					returnTo: returnToWithDid,
272
				},
273
				styleHref,
274
			),
275
		);
276
	} catch (error) {
277
		console.error("Subscribe GET error:", error);
278
		// Session expired - ask the user to sign in again
279
		return c.html(
280
			renderHandleForm(
281
				{
282
					resourceUri: publicationUri,
283
					resourceField: "publicationUri",
284
					loginPath: "/subscribe/login",
285
					title: "Subscribe on Sequoia",
286
					description:
287
						"Enter your Bluesky handle to subscribe to this publication.",
288
					buttonLabel: "Continue on Bluesky",
289
					returnTo,
290
					error: "Session expired. Please sign in again.",
291
					action,
292
				},
293
				styleHref,
294
			),
295
		);
296
	}
297
});
298
299
// ============================================================================
300
// GET /subscribe/check?publicationUri=at://...
301
//
302
// JSON-only endpoint for the web component to check subscription status.
303
//
304
// Responses:
305
//   200 { subscribed: true, recordUri: string }
306
//   200 { subscribed: false }
307
//   400 { error: string }
308
//   401 { authenticated: false }
309
// ============================================================================
310
311
subscribe.get("/check", async (c) => {
312
	const publicationUri = c.req.query("publicationUri");
313
314
	if (!publicationUri || !publicationUri.startsWith("at://")) {
315
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
316
	}
317
318
	// Prefer the server-side session DID; fall back to a client-provided DID
319
	// (stored by the web component from a previous subscribe flow).
320
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
321
	if (!did || !did.startsWith("did:")) {
322
		return c.json({ authenticated: false }, 401);
323
	}
324
325
	try {
326
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
327
		const session = await client.restore(did);
328
		const agent = new Agent(session);
329
		const recordUri = await findExistingRecord(
330
			agent,
331
			did,
332
			COLLECTION,
333
			"publication",
334
			publicationUri,
335
		);
336
		return recordUri
337
			? c.json({ subscribed: true, recordUri })
338
			: c.json({ subscribed: false });
339
	} catch {
340
		return c.json({ authenticated: false }, 401);
341
	}
342
});
343
344
// ============================================================================
345
// POST /subscribe/login
346
//
347
// Handles the handle-entry form submission. Stores the return URL in a cookie
348
// so the OAuth callback in auth.ts can redirect back to /subscribe after auth.
349
// ============================================================================
350
351
subscribe.post("/login", async (c) => {
352
	const body = await c.req.parseBody();
353
	const handle = (body.handle as string | undefined)?.trim();
354
	const publicationUri = body.publicationUri as string | undefined;
355
	const formReturnTo = (body.returnTo as string | undefined) || undefined;
356
	const formAction = (body.action as string | undefined) || undefined;
357
358
	if (!handle || !publicationUri) {
359
		const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
360
		return c.html(
361
			renderError("Missing handle or publication URI.", styleHref),
362
			400,
363
		);
364
	}
365
366
	const returnTo =
367
		`${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` +
368
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
369
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
370
	setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
371
372
	return c.redirect(
373
		`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
374
	);
375
});
376
377
export default subscribe;