packages/server/src/routes/subscribe.ts 8.0 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 type { Env } from "../env";
7
import {
8
	findExistingRecord,
9
	renderError,
10
	renderHandleForm,
11
	renderSuccess,
12
	withReturnToParam,
13
} from "./lib";
14
15
type Variables = { env: Env; db: Database };
16
17
const subscribe = new Hono<{ Variables: Variables }>();
18
19
const COLLECTION = "site.standard.graph.subscription";
20
21
// ============================================================================
22
// POST /subscribe
23
// ============================================================================
24
25
subscribe.post("/", async (c) => {
26
	const env = c.get("env");
27
	const db = c.get("db");
28
29
	let publicationUri: string;
30
	try {
31
		const body = await c.req.json<{ publicationUri?: string }>();
32
		publicationUri = body.publicationUri ?? "";
33
	} catch {
34
		return c.json({ error: "Invalid JSON body" }, 400);
35
	}
36
37
	if (!publicationUri || !publicationUri.startsWith("at://")) {
38
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
39
	}
40
41
	const did = getSessionDid(c);
42
	if (!did) {
43
		const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
44
		return c.json({ authenticated: false, subscribeUrl }, 401);
45
	}
46
47
	try {
48
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
49
		const session = await client.restore(did);
50
		const agent = new Agent(session);
51
52
		const existingUri = await findExistingRecord(
53
			agent,
54
			did,
55
			COLLECTION,
56
			"publication",
57
			publicationUri,
58
		);
59
		if (existingUri) {
60
			return c.json({
61
				subscribed: true,
62
				existing: true,
63
				recordUri: existingUri,
64
			});
65
		}
66
67
		const result = await agent.com.atproto.repo.createRecord({
68
			repo: did,
69
			collection: COLLECTION,
70
			record: {
71
				$type: COLLECTION,
72
				publication: publicationUri,
73
			},
74
		});
75
76
		return c.json({
77
			subscribed: true,
78
			existing: false,
79
			recordUri: result.data.uri,
80
		});
81
	} catch (error) {
82
		console.error("Subscribe POST error:", error);
83
		const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
84
		return c.json({ authenticated: false, subscribeUrl }, 401);
85
	}
86
});
87
88
// ============================================================================
89
// GET /subscribe
90
// ============================================================================
91
92
subscribe.get("/", async (c) => {
93
	const env = c.get("env");
94
	const db = c.get("db");
95
96
	const publicationUri = c.req.query("publicationUri");
97
	const action = c.req.query("action");
98
99
	if (action && action !== "unsubscribe") {
100
		return c.html(renderError(`Unsupported action: ${action}`), 400);
101
	}
102
103
	if (!publicationUri || !publicationUri.startsWith("at://")) {
104
		return c.html(renderError("Missing or invalid publication URI."), 400);
105
	}
106
107
	const referer = c.req.header("referer");
108
	const returnTo =
109
		c.req.query("returnTo") ??
110
		(referer && !referer.includes("/subscribe") ? referer : undefined);
111
112
	const did = getSessionDid(c);
113
	if (!did) {
114
		return c.html(
115
			renderHandleForm({
116
				resourceUri: publicationUri,
117
				resourceField: "publicationUri",
118
				loginPath: "/subscribe/login",
119
				title: "Subscribe on Sequoia",
120
				description:
121
					"Enter your Bluesky handle to subscribe to this publication.",
122
				buttonLabel: "Continue on Bluesky",
123
				returnTo,
124
				action,
125
			}),
126
		);
127
	}
128
129
	try {
130
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
131
		const session = await client.restore(did);
132
		const agent = new Agent(session);
133
134
		if (action === "unsubscribe") {
135
			const existingUri = await findExistingRecord(
136
				agent,
137
				did,
138
				COLLECTION,
139
				"publication",
140
				publicationUri,
141
			);
142
			if (existingUri) {
143
				const rkey = existingUri.split("/").pop()!;
144
				await agent.com.atproto.repo.deleteRecord({
145
					repo: did,
146
					collection: COLLECTION,
147
					rkey,
148
				});
149
			}
150
151
			let cleanReturnTo = returnTo;
152
			if (cleanReturnTo) {
153
				try {
154
					const rtUrl = new URL(cleanReturnTo);
155
					rtUrl.searchParams.delete("sequoia_did");
156
					cleanReturnTo = rtUrl.toString();
157
				} catch {
158
					// keep as-is
159
				}
160
			}
161
162
			return c.html(
163
				renderSuccess({
164
					resourceUri: publicationUri,
165
					resourceLabel: "Publication",
166
					recordUri: null,
167
					heading: "Unsubscribed",
168
					msg: existingUri
169
						? "You've successfully unsubscribed!"
170
						: "You weren't subscribed to this publication.",
171
					returnTo: withReturnToParam(
172
						cleanReturnTo,
173
						"sequoia_unsubscribed",
174
						"1",
175
					),
176
				}),
177
			);
178
		}
179
180
		const existingUri = await findExistingRecord(
181
			agent,
182
			did,
183
			COLLECTION,
184
			"publication",
185
			publicationUri,
186
		);
187
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
188
189
		if (existingUri) {
190
			return c.html(
191
				renderSuccess({
192
					resourceUri: publicationUri,
193
					resourceLabel: "Publication",
194
					recordUri: existingUri,
195
					heading: "Subscribed",
196
					msg: "You're already subscribed to this publication.",
197
					returnTo: returnToWithDid,
198
				}),
199
			);
200
		}
201
202
		const result = await agent.com.atproto.repo.createRecord({
203
			repo: did,
204
			collection: COLLECTION,
205
			record: {
206
				$type: COLLECTION,
207
				publication: publicationUri,
208
			},
209
		});
210
211
		return c.html(
212
			renderSuccess({
213
				resourceUri: publicationUri,
214
				resourceLabel: "Publication",
215
				recordUri: result.data.uri,
216
				heading: "Subscribed",
217
				msg: "You've successfully subscribed!",
218
				returnTo: returnToWithDid,
219
			}),
220
		);
221
	} catch (error) {
222
		console.error("Subscribe GET error:", error);
223
		return c.html(
224
			renderHandleForm({
225
				resourceUri: publicationUri,
226
				resourceField: "publicationUri",
227
				loginPath: "/subscribe/login",
228
				title: "Subscribe on Sequoia",
229
				description:
230
					"Enter your Bluesky handle to subscribe to this publication.",
231
				buttonLabel: "Continue on Bluesky",
232
				returnTo,
233
				error: "Session expired. Please sign in again.",
234
				action,
235
			}),
236
		);
237
	}
238
});
239
240
// ============================================================================
241
// GET /subscribe/check
242
// ============================================================================
243
244
subscribe.get("/check", async (c) => {
245
	const env = c.get("env");
246
	const db = c.get("db");
247
248
	const publicationUri = c.req.query("publicationUri");
249
250
	if (!publicationUri || !publicationUri.startsWith("at://")) {
251
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
252
	}
253
254
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
255
	if (!did || !did.startsWith("did:")) {
256
		return c.json({ authenticated: false }, 401);
257
	}
258
259
	try {
260
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
261
		const session = await client.restore(did);
262
		const agent = new Agent(session);
263
		const recordUri = await findExistingRecord(
264
			agent,
265
			did,
266
			COLLECTION,
267
			"publication",
268
			publicationUri,
269
		);
270
		return recordUri
271
			? c.json({ subscribed: true, recordUri })
272
			: c.json({ subscribed: false });
273
	} catch {
274
		return c.json({ authenticated: false }, 401);
275
	}
276
});
277
278
// ============================================================================
279
// POST /subscribe/login
280
// ============================================================================
281
282
subscribe.post("/login", async (c) => {
283
	const env = c.get("env");
284
285
	const body = await c.req.parseBody();
286
	const handle = (body.handle as string | undefined)?.trim();
287
	const publicationUri = body.publicationUri as string | undefined;
288
	const formReturnTo = (body.returnTo as string | undefined) || undefined;
289
	const formAction = (body.action as string | undefined) || undefined;
290
291
	if (!handle || !publicationUri) {
292
		return c.html(renderError("Missing handle or publication URI."), 400);
293
	}
294
295
	const returnTo =
296
		`${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` +
297
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
298
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
299
	setReturnToCookie(c, returnTo, env.CLIENT_URL);
300
301
	return c.redirect(
302
		`${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
303
	);
304
});
305
306
export default subscribe;