docs/src/routes/recommend.ts 9.5 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 recommend = new Hono<{ Bindings: Env }>();
43
44
const COLLECTION = "site.standard.graph.recommend";
45
46
// ============================================================================
47
// POST /recommend
48
// ============================================================================
49
50
recommend.post("/", async (c) => {
51
	let documentUri: string;
52
	try {
53
		const body = await c.req.json<{ documentUri?: string }>();
54
		documentUri = body.documentUri ?? "";
55
	} catch {
56
		return c.json({ error: "Invalid JSON body" }, 400);
57
	}
58
59
	if (!documentUri || !documentUri.startsWith("at://")) {
60
		return c.json({ error: "Missing or invalid documentUri" }, 400);
61
	}
62
63
	const did = getSessionDid(c);
64
	if (!did) {
65
		const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
66
		return c.json({ authenticated: false, subscribeUrl }, 401);
67
	}
68
69
	try {
70
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
71
		const session = await client.restore(did);
72
		const agent = new Agent(session);
73
74
		const existingUri = await findExistingRecord(
75
			agent,
76
			did,
77
			COLLECTION,
78
			"document",
79
			documentUri,
80
		);
81
		if (existingUri) {
82
			return c.json({
83
				recommended: true,
84
				existing: true,
85
				recordUri: existingUri,
86
			});
87
		}
88
89
		const result = await agent.com.atproto.repo.createRecord({
90
			repo: did,
91
			collection: COLLECTION,
92
			record: {
93
				$type: COLLECTION,
94
				document: documentUri,
95
				createdAt: new Date().toISOString(),
96
			},
97
		});
98
99
		return c.json({
100
			recommended: true,
101
			existing: false,
102
			recordUri: result.data.uri,
103
		});
104
	} catch (error) {
105
		console.error("Recommend POST error:", error);
106
		const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
107
		return c.json({ authenticated: false, subscribeUrl }, 401);
108
	}
109
});
110
111
// ============================================================================
112
// GET /recommend?documentUri=at://...
113
//
114
// Full-page OAuth + recommendation flow. Unauthenticated users land here after
115
// the component redirects them, and authenticated users land here after the
116
// OAuth callback (via the login_return_to cookie set in POST /recommend/login).
117
// ============================================================================
118
119
recommend.get("/", async (c) => {
120
	const documentUri = c.req.query("documentUri");
121
	const action = c.req.query("action");
122
	const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
123
124
	if (action && action !== "remove") {
125
		return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
126
	}
127
128
	if (!documentUri || !documentUri.startsWith("at://")) {
129
		return c.html(
130
			renderError("Missing or invalid document URI.", styleHref),
131
			400,
132
		);
133
	}
134
135
	// Prefer an explicit returnTo query param (survives the OAuth round-trip);
136
	// fall back to the Referer header on the first visit, ignoring self-referrals.
137
	const referer = c.req.header("referer");
138
	const returnTo =
139
		c.req.query("returnTo") ??
140
		(referer && !referer.includes("/recommend") ? referer : undefined);
141
142
	const did = getSessionDid(c);
143
	if (!did) {
144
		return c.html(
145
			renderHandleForm(
146
				{
147
					resourceUri: documentUri,
148
					resourceField: "documentUri",
149
					loginPath: "/recommend/login",
150
					title: "Recommend on Sequoia",
151
					description: "Enter your Bluesky handle to recommend this document.",
152
					buttonLabel: "Continue on Bluesky",
153
					returnTo,
154
					action,
155
				},
156
				styleHref,
157
			),
158
		);
159
	}
160
161
	try {
162
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
163
		const session = await client.restore(did);
164
		const agent = new Agent(session);
165
166
		if (action === "remove") {
167
			const existingUri = await findExistingRecord(
168
				agent,
169
				did,
170
				COLLECTION,
171
				"document",
172
				documentUri,
173
			);
174
			if (existingUri) {
175
				const rkey = existingUri.split("/").pop()!;
176
				await agent.com.atproto.repo.deleteRecord({
177
					repo: did,
178
					collection: COLLECTION,
179
					rkey,
180
				});
181
			}
182
183
			return c.html(
184
				renderSuccess(
185
					{
186
						resourceUri: documentUri,
187
						resourceLabel: "Document",
188
						recordUri: null,
189
						heading: "Recommendation Removed \u2713",
190
						msg: existingUri
191
							? "You've successfully removed your recommendation."
192
							: "You hadn't recommended this document.",
193
						returnTo: withReturnToParam(returnTo, "sequoia_did", did),
194
					},
195
					styleHref,
196
				),
197
			);
198
		}
199
200
		const existingUri = await findExistingRecord(
201
			agent,
202
			did,
203
			COLLECTION,
204
			"document",
205
			documentUri,
206
		);
207
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
208
209
		if (existingUri) {
210
			return c.html(
211
				renderSuccess(
212
					{
213
						resourceUri: documentUri,
214
						resourceLabel: "Document",
215
						recordUri: existingUri,
216
						heading: "Recommended \u2713",
217
						msg: "You've already recommended this document.",
218
						returnTo: returnToWithDid,
219
					},
220
					styleHref,
221
				),
222
			);
223
		}
224
225
		const result = await agent.com.atproto.repo.createRecord({
226
			repo: did,
227
			collection: COLLECTION,
228
			record: {
229
				$type: COLLECTION,
230
				document: documentUri,
231
				createdAt: new Date().toISOString(),
232
			},
233
		});
234
235
		return c.html(
236
			renderSuccess(
237
				{
238
					resourceUri: documentUri,
239
					resourceLabel: "Document",
240
					recordUri: result.data.uri,
241
					heading: "Recommended \u2713",
242
					msg: "You've successfully recommended this document!",
243
					returnTo: returnToWithDid,
244
				},
245
				styleHref,
246
			),
247
		);
248
	} catch (error) {
249
		console.error("Recommend GET error:", error);
250
		// Session expired - ask the user to sign in again
251
		return c.html(
252
			renderHandleForm(
253
				{
254
					resourceUri: documentUri,
255
					resourceField: "documentUri",
256
					loginPath: "/recommend/login",
257
					title: "Recommend on Sequoia",
258
					description: "Enter your Bluesky handle to recommend this document.",
259
					buttonLabel: "Continue on Bluesky",
260
					returnTo,
261
					error: "Session expired. Please sign in again.",
262
					action,
263
				},
264
				styleHref,
265
			),
266
		);
267
	}
268
});
269
270
// ============================================================================
271
// GET /recommend/check?documentUri=at://...
272
//
273
// JSON-only endpoint for the web component to check recommendation status.
274
//
275
// Responses:
276
//   200 { recommended: true, recordUri: string }
277
//   200 { recommended: false }
278
//   400 { error: string }
279
//   401 { authenticated: false }
280
// ============================================================================
281
282
recommend.get("/check", async (c) => {
283
	const documentUri = c.req.query("documentUri");
284
285
	if (!documentUri || !documentUri.startsWith("at://")) {
286
		return c.json({ error: "Missing or invalid documentUri" }, 400);
287
	}
288
289
	// Prefer the server-side session DID; fall back to a client-provided DID
290
	// (stored by the web component from a previous recommend flow).
291
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
292
	if (!did || !did.startsWith("did:")) {
293
		return c.json({ authenticated: false }, 401);
294
	}
295
296
	try {
297
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
298
		const session = await client.restore(did);
299
		const agent = new Agent(session);
300
		const recordUri = await findExistingRecord(
301
			agent,
302
			did,
303
			COLLECTION,
304
			"document",
305
			documentUri,
306
		);
307
		return recordUri
308
			? c.json({ recommended: true, recordUri })
309
			: c.json({ recommended: false });
310
	} catch {
311
		return c.json({ authenticated: false }, 401);
312
	}
313
});
314
315
// ============================================================================
316
// POST /recommend/login
317
//
318
// Handles the handle-entry form submission. Stores the return URL in a cookie
319
// so the OAuth callback in auth.ts can redirect back to /recommend after auth.
320
// ============================================================================
321
322
recommend.post("/login", async (c) => {
323
	const body = await c.req.parseBody();
324
	const handle = (body.handle as string | undefined)?.trim();
325
	const documentUri = body.documentUri as string | undefined;
326
	const formReturnTo = (body.returnTo as string | undefined) || undefined;
327
	const formAction = (body.action as string | undefined) || undefined;
328
329
	if (!handle || !documentUri) {
330
		const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
331
		return c.html(
332
			renderError("Missing handle or document URI.", styleHref),
333
			400,
334
		);
335
	}
336
337
	const returnTo =
338
		`${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` +
339
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
340
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
341
	setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
342
343
	return c.redirect(
344
		`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
345
	);
346
});
347
348
export default recommend;