docs/src/routes/subscribe.ts 10.1 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
6
interface Env {
7
	ASSETS: Fetcher;
8
	SEQUOIA_SESSIONS: KVNamespace;
9
	CLIENT_URL: string;
10
}
11
12
// Cache the vocs-generated stylesheet href across requests (changes on rebuild).
13
let _vocsStyleHref: string | null = null;
14
15
async function getVocsStyleHref(assets: Fetcher, baseUrl: string): Promise<string> {
16
	if (_vocsStyleHref) return _vocsStyleHref;
17
	try {
18
		const indexUrl = new URL("/", baseUrl).toString();
19
		const res = await assets.fetch(indexUrl);
20
		const html = await res.text();
21
		const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/);
22
		if (match?.[1]) {
23
			_vocsStyleHref = match[1];
24
			return match[1];
25
		}
26
	} catch {
27
		// Fall back to the custom stylesheet which at least provides --sequoia-* vars
28
	}
29
	return "/styles.css";
30
}
31
32
const subscribe = new Hono<{ Bindings: Env }>();
33
34
const COLLECTION = "site.standard.graph.subscription";
35
36
// ============================================================================
37
// Helpers
38
// ============================================================================
39
40
/**
41
 * Scan the user's repo for an existing site.standard.graph.subscription
42
 * matching the given publication URI. Returns the record AT-URI if found.
43
 */
44
async function findExistingSubscription(
45
	agent: Agent,
46
	did: string,
47
	publicationUri: string,
48
): Promise<string | null> {
49
	let cursor: string | undefined;
50
51
	do {
52
		const result = await agent.com.atproto.repo.listRecords({
53
			repo: did,
54
			collection: COLLECTION,
55
			limit: 100,
56
			cursor,
57
		});
58
59
		for (const record of result.data.records) {
60
			const value = record.value as { publication?: string };
61
			if (value.publication === publicationUri) {
62
				return record.uri;
63
			}
64
		}
65
66
		cursor = result.data.cursor;
67
	} while (cursor);
68
69
	return null;
70
}
71
72
// ============================================================================
73
// POST /subscribe
74
//
75
// Called via fetch() from the sequoia-subscribe web component.
76
// Body JSON: { publicationUri: string }
77
//
78
// Responses:
79
//   200 { subscribed: true, existing: boolean, recordUri: string }
80
//   400 { error: string }
81
//   401 { authenticated: false, subscribeUrl: string }
82
// ============================================================================
83
84
subscribe.post("/", async (c) => {
85
	let publicationUri: string;
86
	try {
87
		const body = await c.req.json<{ publicationUri?: string }>();
88
		publicationUri = body.publicationUri ?? "";
89
	} catch {
90
		return c.json({ error: "Invalid JSON body" }, 400);
91
	}
92
93
	if (!publicationUri || !publicationUri.startsWith("at://")) {
94
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
95
	}
96
97
	const did = getSessionDid(c);
98
	if (!did) {
99
		const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
100
		return c.json({ authenticated: false, subscribeUrl }, 401);
101
	}
102
103
	try {
104
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
105
		const session = await client.restore(did);
106
		const agent = new Agent(session);
107
108
		const existingUri = await findExistingSubscription(agent, did, publicationUri);
109
		if (existingUri) {
110
			return c.json({ subscribed: true, existing: true, recordUri: existingUri });
111
		}
112
113
		const result = await agent.com.atproto.repo.createRecord({
114
			repo: did,
115
			collection: COLLECTION,
116
			record: {
117
				$type: COLLECTION,
118
				publication: publicationUri,
119
			},
120
		});
121
122
		return c.json({ subscribed: true, existing: false, recordUri: result.data.uri });
123
	} catch (error) {
124
		console.error("Subscribe POST error:", error);
125
		// Treat expired/missing session as unauthenticated
126
		const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
127
		return c.json({ authenticated: false, subscribeUrl }, 401);
128
	}
129
});
130
131
// ============================================================================
132
// GET /subscribe?publicationUri=at://...
133
//
134
// Full-page OAuth + subscription flow. Unauthenticated users land here after
135
// the component redirects them, and authenticated users land here after the
136
// OAuth callback (via the login_return_to cookie set in POST /subscribe/login).
137
// ============================================================================
138
139
subscribe.get("/", async (c) => {
140
	const publicationUri = c.req.query("publicationUri");
141
	const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
142
143
	if (!publicationUri || !publicationUri.startsWith("at://")) {
144
		return c.html(renderError("Missing or invalid publication URI.", styleHref), 400);
145
	}
146
147
	const did = getSessionDid(c);
148
	if (!did) {
149
		return c.html(renderHandleForm(publicationUri, styleHref));
150
	}
151
152
	try {
153
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
154
		const session = await client.restore(did);
155
		const agent = new Agent(session);
156
157
		const existingUri = await findExistingSubscription(agent, did, publicationUri);
158
		if (existingUri) {
159
			return c.html(renderSuccess(publicationUri, existingUri, true, styleHref));
160
		}
161
162
		const result = await agent.com.atproto.repo.createRecord({
163
			repo: did,
164
			collection: COLLECTION,
165
			record: {
166
				$type: COLLECTION,
167
				publication: publicationUri,
168
			},
169
		});
170
171
		return c.html(renderSuccess(publicationUri, result.data.uri, false, styleHref));
172
	} catch (error) {
173
		console.error("Subscribe GET error:", error);
174
		// Session expired - ask the user to sign in again
175
		return c.html(renderHandleForm(publicationUri, styleHref, "Session expired. Please sign in again."));
176
	}
177
});
178
179
// ============================================================================
180
// POST /subscribe/login
181
//
182
// Handles the handle-entry form submission. Stores the return URL in a cookie
183
// so the OAuth callback in auth.ts can redirect back to /subscribe after auth.
184
// ============================================================================
185
186
subscribe.post("/login", async (c) => {
187
	const body = await c.req.parseBody();
188
	const handle = (body["handle"] as string | undefined)?.trim();
189
	const publicationUri = body["publicationUri"] as string | undefined;
190
191
	if (!handle || !publicationUri) {
192
		const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
193
		return c.html(renderError("Missing handle or publication URI.", styleHref), 400);
194
	}
195
196
	const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
197
	setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
198
199
	return c.redirect(
200
		`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
201
	);
202
});
203
204
// ============================================================================
205
// HTML rendering
206
// ============================================================================
207
208
function renderHandleForm(publicationUri: string, styleHref: string, error?: string): string {
209
	const errorHtml = error
210
		? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
211
		: "";
212
213
	return page(`
214
		<h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1>
215
		<p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p>
216
		${errorHtml}
217
		<form method="POST" action="/subscribe/login">
218
		<input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
219
			<input
220
				type="text"
221
				name="handle"
222
				placeholder="you.bsky.social"
223
				autocomplete="username"
224
				required
225
				autofocus
226
			/>
227
			<button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button>
228
		</form>
229
	`, styleHref);
230
}
231
232
function renderSuccess(
233
	publicationUri: string,
234
	recordUri: string,
235
	existing: boolean,
236
	styleHref: string,
237
): string {
238
	const msg = existing
239
		? "You're already subscribed to this publication."
240
		: "You've successfully subscribed!";
241
	const escapedPublicationUri = escapeHtml(publicationUri);
242
	const escapedRecordUri = escapeHtml(recordUri);
243
	return page(`
244
		<h1 class="vocs_H1 vocs_Heading">Subscribed โœ“</h1>
245
		<p class="vocs_Paragraph">${msg}</p>
246
		<p class="vocs_Paragraph"><small>Publication: <code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></small></p>
247
		<p class="vocs_Paragraph"><small>Record: <code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></small></p>
248
	`, styleHref);
249
}
250
251
function renderError(message: string, styleHref: string): string {
252
	return page(`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, styleHref);
253
}
254
255
function page(body: string, styleHref: string): string {
256
	return `<!DOCTYPE html>
257
<html lang="en">
258
<head>
259
  <meta charset="UTF-8" />
260
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
261
  <title>Sequoia ยท Subscribe</title>
262
  <link rel="stylesheet" href="${styleHref}" />
263
  <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script>
264
  <style>
265
    .page-container {
266
      max-width: calc(var(--vocs-content_width, 480px) / 1.6);
267
      margin: 4rem auto;
268
      padding: 0 var(--vocs-space_20, 1.25rem);
269
    }
270
    .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); }
271
    .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); }
272
    input[type="text"] {
273
      padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem);
274
      border: 1px solid var(--vocs-color_border, #D5D1C8);
275
      border-radius: var(--vocs-borderRadius_6, 6px);
276
      margin-bottom: var(--vocs-space_20, 1.25rem);
277
	  min-width: 30vh;
278
	  width: 100%;
279
      font-size: var(--vocs-fontSize_16, 1rem);
280
      font-family: inherit;
281
      background: var(--vocs-color_background, #F5F3EF);
282
      color: var(--vocs-color_text, #2C2C2C);
283
    }
284
    input[type="text"]:focus {
285
      border-color: var(--vocs-color_borderAccent, #3A5A40);
286
      outline: 2px solid var(--vocs-color_borderAccent, #3A5A40);
287
      outline-offset: 2px;
288
    }
289
    .error { color: var(--vocs-color_dangerText, #8B3A3A); }
290
  </style>
291
</head>
292
<body>
293
  <div class="page-container">
294
    ${body}
295
  </div>
296
</body>
297
</html>`;
298
}
299
300
function escapeHtml(text: string): string {
301
	return text
302
		.replace(/&/g, "&amp;")
303
		.replace(/</g, "&lt;")
304
		.replace(/>/g, "&gt;")
305
		.replace(/"/g, "&quot;");
306
}
307
308
export default subscribe;