packages/server/src/routes/recommend.ts 7.7 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 recommend = new Hono<{ Variables: Variables }>();
18
19
const COLLECTION = "site.standard.graph.recommend";
20
21
// ============================================================================
22
// POST /recommend
23
// ============================================================================
24
25
recommend.post("/", async (c) => {
26
	const env = c.get("env");
27
	const db = c.get("db");
28
29
	let documentUri: string;
30
	try {
31
		const body = await c.req.json<{ documentUri?: string }>();
32
		documentUri = body.documentUri ?? "";
33
	} catch {
34
		return c.json({ error: "Invalid JSON body" }, 400);
35
	}
36
37
	if (!documentUri || !documentUri.startsWith("at://")) {
38
		return c.json({ error: "Missing or invalid documentUri" }, 400);
39
	}
40
41
	const did = getSessionDid(c);
42
	if (!did) {
43
		const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
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
			"document",
57
			documentUri,
58
		);
59
		if (existingUri) {
60
			return c.json({
61
				recommended: 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
				document: documentUri,
73
				createdAt: new Date().toISOString(),
74
			},
75
		});
76
77
		return c.json({
78
			recommended: true,
79
			existing: false,
80
			recordUri: result.data.uri,
81
		});
82
	} catch (error) {
83
		console.error("Recommend POST error:", error);
84
		const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
85
		return c.json({ authenticated: false, subscribeUrl }, 401);
86
	}
87
});
88
89
// ============================================================================
90
// GET /recommend
91
// ============================================================================
92
93
recommend.get("/", async (c) => {
94
	const env = c.get("env");
95
	const db = c.get("db");
96
97
	const documentUri = c.req.query("documentUri");
98
	const action = c.req.query("action");
99
100
	if (action && action !== "remove") {
101
		return c.html(renderError(`Unsupported action: ${action}`), 400);
102
	}
103
104
	if (!documentUri || !documentUri.startsWith("at://")) {
105
		return c.html(renderError("Missing or invalid document URI."), 400);
106
	}
107
108
	const referer = c.req.header("referer");
109
	const returnTo =
110
		c.req.query("returnTo") ??
111
		(referer && !referer.includes("/recommend") ? referer : undefined);
112
113
	const did = getSessionDid(c);
114
	if (!did) {
115
		return c.html(
116
			renderHandleForm({
117
				resourceUri: documentUri,
118
				resourceField: "documentUri",
119
				loginPath: "/recommend/login",
120
				title: "Recommend on Sequoia",
121
				description: "Enter your Bluesky handle to recommend this document.",
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 === "remove") {
135
			const existingUri = await findExistingRecord(
136
				agent,
137
				did,
138
				COLLECTION,
139
				"document",
140
				documentUri,
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
			return c.html(
152
				renderSuccess({
153
					resourceUri: documentUri,
154
					resourceLabel: "Document",
155
					recordUri: null,
156
					heading: "Recommendation Removed",
157
					msg: existingUri
158
						? "You've successfully removed your recommendation."
159
						: "You hadn't recommended this document.",
160
					returnTo: withReturnToParam(returnTo, "sequoia_did", did),
161
				}),
162
			);
163
		}
164
165
		const existingUri = await findExistingRecord(
166
			agent,
167
			did,
168
			COLLECTION,
169
			"document",
170
			documentUri,
171
		);
172
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
173
174
		if (existingUri) {
175
			return c.html(
176
				renderSuccess({
177
					resourceUri: documentUri,
178
					resourceLabel: "Document",
179
					recordUri: existingUri,
180
					heading: "Recommended",
181
					msg: "You've already recommended this document.",
182
					returnTo: returnToWithDid,
183
				}),
184
			);
185
		}
186
187
		const result = await agent.com.atproto.repo.createRecord({
188
			repo: did,
189
			collection: COLLECTION,
190
			record: {
191
				$type: COLLECTION,
192
				document: documentUri,
193
				createdAt: new Date().toISOString(),
194
			},
195
		});
196
197
		return c.html(
198
			renderSuccess({
199
				resourceUri: documentUri,
200
				resourceLabel: "Document",
201
				recordUri: result.data.uri,
202
				heading: "Recommended",
203
				msg: "You've successfully recommended this document!",
204
				returnTo: returnToWithDid,
205
			}),
206
		);
207
	} catch (error) {
208
		console.error("Recommend GET error:", error);
209
		return c.html(
210
			renderHandleForm({
211
				resourceUri: documentUri,
212
				resourceField: "documentUri",
213
				loginPath: "/recommend/login",
214
				title: "Recommend on Sequoia",
215
				description: "Enter your Bluesky handle to recommend this document.",
216
				buttonLabel: "Continue on Bluesky",
217
				returnTo,
218
				error: "Session expired. Please sign in again.",
219
				action,
220
			}),
221
		);
222
	}
223
});
224
225
// ============================================================================
226
// GET /recommend/check
227
// ============================================================================
228
229
recommend.get("/check", async (c) => {
230
	const env = c.get("env");
231
	const db = c.get("db");
232
233
	const documentUri = c.req.query("documentUri");
234
235
	if (!documentUri || !documentUri.startsWith("at://")) {
236
		return c.json({ error: "Missing or invalid documentUri" }, 400);
237
	}
238
239
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
240
	if (!did || !did.startsWith("did:")) {
241
		return c.json({ authenticated: false }, 401);
242
	}
243
244
	try {
245
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
246
		const session = await client.restore(did);
247
		const agent = new Agent(session);
248
		const recordUri = await findExistingRecord(
249
			agent,
250
			did,
251
			COLLECTION,
252
			"document",
253
			documentUri,
254
		);
255
		return recordUri
256
			? c.json({ recommended: true, recordUri })
257
			: c.json({ recommended: false });
258
	} catch {
259
		return c.json({ authenticated: false }, 401);
260
	}
261
});
262
263
// ============================================================================
264
// POST /recommend/login
265
// ============================================================================
266
267
recommend.post("/login", async (c) => {
268
	const env = c.get("env");
269
270
	const body = await c.req.parseBody();
271
	const handle = (body.handle as string | undefined)?.trim();
272
	const documentUri = body.documentUri as string | undefined;
273
	const formReturnTo = (body.returnTo as string | undefined) || undefined;
274
	const formAction = (body.action as string | undefined) || undefined;
275
276
	if (!handle || !documentUri) {
277
		return c.html(renderError("Missing handle or document URI."), 400);
278
	}
279
280
	const returnTo =
281
		`${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` +
282
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
283
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
284
	setReturnToCookie(c, returnTo, env.CLIENT_URL);
285
286
	return c.redirect(
287
		`${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
288
	);
289
});
290
291
export default recommend;