@dwk/microsub 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +92 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +102 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +102 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/consumer.d.ts +40 -0
- package/dist/consumer.d.ts.map +1 -0
- package/dist/consumer.js +87 -0
- package/dist/consumer.js.map +1 -0
- package/dist/discovery.d.ts +59 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +190 -0
- package/dist/discovery.js.map +1 -0
- package/dist/fetch.d.ts +28 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +72 -0
- package/dist/fetch.js.map +1 -0
- package/dist/handler.d.ts +24 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +434 -0
- package/dist/handler.js.map +1 -0
- package/dist/hfeed.d.ts +25 -0
- package/dist/hfeed.d.ts.map +1 -0
- package/dist/hfeed.js +252 -0
- package/dist/hfeed.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jf2.d.ts +69 -0
- package/dist/jf2.d.ts.map +1 -0
- package/dist/jf2.js +295 -0
- package/dist/jf2.js.map +1 -0
- package/dist/log.d.ts +44 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +42 -0
- package/dist/log.js.map +1 -0
- package/dist/poll.d.ts +22 -0
- package/dist/poll.d.ts.map +1 -0
- package/dist/poll.js +39 -0
- package/dist/poll.js.map +1 -0
- package/dist/queue.d.ts +25 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +13 -0
- package/dist/queue.js.map +1 -0
- package/dist/replay.d.ts +34 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +49 -0
- package/dist/replay.js.map +1 -0
- package/dist/safe-fetch.d.ts +86 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +311 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/store.d.ts +131 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +393 -0
- package/dist/store.js.map +1 -0
- package/dist/xml.d.ts +51 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +196 -0
- package/dist/xml.js.map +1 -0
- package/package.json +49 -0
- package/src/auth.ts +184 -0
- package/src/config.ts +156 -0
- package/src/consumer.ts +140 -0
- package/src/discovery.ts +270 -0
- package/src/fetch.ts +82 -0
- package/src/handler.ts +594 -0
- package/src/hfeed.ts +287 -0
- package/src/index.ts +86 -0
- package/src/jf2.ts +394 -0
- package/src/log.ts +46 -0
- package/src/poll.ts +72 -0
- package/src/queue.ts +26 -0
- package/src/replay.ts +68 -0
- package/src/safe-fetch.ts +346 -0
- package/src/store.ts +644 -0
- package/src/xml.ts +229 -0
package/src/handler.ts
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Microsub fetch handler: a single endpoint whose `action` (and `method`)
|
|
3
|
+
* parameters select the operation — channel management, following, the JF2
|
|
4
|
+
* timeline, search, and preview — wired to the D1 store and `@dwk/indieauth`
|
|
5
|
+
* token validation. Routing matches the request pathname against the configured
|
|
6
|
+
* endpoint path, so the handler is mountable under any prefix.
|
|
7
|
+
*
|
|
8
|
+
* The read path serves stored entries only; it never fetches a source inline.
|
|
9
|
+
* Following a feed discovers it, populates the timeline from the discovery
|
|
10
|
+
* fetch, and enqueues a poll job so the feed's poll cache is primed.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExecutionContext } from "@cloudflare/workers-types";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
authorize,
|
|
19
|
+
tokenFromHeader,
|
|
20
|
+
type AuthEnv,
|
|
21
|
+
type AuthResult,
|
|
22
|
+
} from "./auth";
|
|
23
|
+
import {
|
|
24
|
+
resolveConfig,
|
|
25
|
+
type MicrosubConfig,
|
|
26
|
+
type MicrosubEnv,
|
|
27
|
+
type ResolvedConfig,
|
|
28
|
+
} from "./config";
|
|
29
|
+
import { discoverFeed } from "./discovery";
|
|
30
|
+
import { orderEntriesForInsert } from "./jf2";
|
|
31
|
+
import { MicrosubLogEvent } from "./log";
|
|
32
|
+
import {
|
|
33
|
+
createMicrosubStore,
|
|
34
|
+
NOTIFICATIONS_CHANNEL,
|
|
35
|
+
type MicrosubStore,
|
|
36
|
+
} from "./store";
|
|
37
|
+
|
|
38
|
+
/** A `fetch`-compatible Worker handler. */
|
|
39
|
+
export type MicrosubHandler = (
|
|
40
|
+
request: Request,
|
|
41
|
+
env: MicrosubEnv,
|
|
42
|
+
ctx: ExecutionContext,
|
|
43
|
+
) => Promise<Response>;
|
|
44
|
+
|
|
45
|
+
const CORS_HEADERS = {
|
|
46
|
+
"access-control-allow-origin": "*",
|
|
47
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
48
|
+
"access-control-allow-headers": "Authorization, DPoP, Content-Type",
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
function json(body: unknown, status = 200): Response {
|
|
52
|
+
return new Response(JSON.stringify(body), {
|
|
53
|
+
status,
|
|
54
|
+
headers: { "content-type": "application/json", ...CORS_HEADERS },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A Microsub error body (`{ error, error_description }`) at `status`. */
|
|
59
|
+
function error(code: string, description: string, status: number): Response {
|
|
60
|
+
return json({ error: code, error_description: description }, status);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emit(
|
|
64
|
+
config: ResolvedConfig,
|
|
65
|
+
level: "info" | "warn",
|
|
66
|
+
event: string,
|
|
67
|
+
fields?: Record<string, unknown>,
|
|
68
|
+
): void {
|
|
69
|
+
config.logger[level](event, fields);
|
|
70
|
+
config.metrics.count(event, fields);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Fail loudly when a required binding is absent (composition contract). */
|
|
74
|
+
function assertBindings(env: MicrosubEnv): void {
|
|
75
|
+
if (!env.MICROSUB_DB) {
|
|
76
|
+
throw new Error("@dwk/microsub: missing required D1 binding `MICROSUB_DB`");
|
|
77
|
+
}
|
|
78
|
+
if (!env.MICROSUB_QUEUE) {
|
|
79
|
+
throw new Error("@dwk/microsub: missing required binding `MICROSUB_QUEUE`");
|
|
80
|
+
}
|
|
81
|
+
if (!env.AUTH_DB) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"@dwk/microsub: missing required D1 binding `AUTH_DB` (IndieAuth token store)",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!env.TOKEN_SIGNING_KEY || typeof env.TOKEN_SIGNING_KEY !== "string") {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"@dwk/microsub: missing required secret binding `TOKEN_SIGNING_KEY`",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Merge the query string and (for POST) the form/JSON body into one param bag. */
|
|
94
|
+
async function readParams(
|
|
95
|
+
request: Request,
|
|
96
|
+
method: string,
|
|
97
|
+
): Promise<URLSearchParams> {
|
|
98
|
+
const params = new URLSearchParams(new URL(request.url).search);
|
|
99
|
+
if (method !== "POST") return params;
|
|
100
|
+
|
|
101
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
102
|
+
if (contentType.includes("application/json")) {
|
|
103
|
+
try {
|
|
104
|
+
const body = (await request.json()) as Record<string, unknown>;
|
|
105
|
+
if (body && typeof body === "object") {
|
|
106
|
+
for (const [key, value] of Object.entries(body)) {
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
for (const v of value) params.append(key, String(v));
|
|
109
|
+
} else if (value !== null && value !== undefined) {
|
|
110
|
+
params.append(key, String(value));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// malformed JSON: fall through with query-only params.
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
try {
|
|
119
|
+
const form = await request.formData();
|
|
120
|
+
for (const [key, value] of form) {
|
|
121
|
+
if (typeof value === "string") params.append(key, value);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// malformed/empty body: query-only params.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return params;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** All values for a param under both its bare and `[]` array spellings. */
|
|
131
|
+
function getAll(params: URLSearchParams, name: string): string[] {
|
|
132
|
+
return [...params.getAll(name), ...params.getAll(`${name}[]`)];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const now = (): number => Math.floor(Date.now() / 1000);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Authorize a request, emitting an `AuthRejected` event on failure. The token is
|
|
139
|
+
* taken from the `Authorization` header, falling back to an `access_token`
|
|
140
|
+
* parameter (which Microsub clients may use), but never both.
|
|
141
|
+
*/
|
|
142
|
+
async function requireAuth(
|
|
143
|
+
request: Request,
|
|
144
|
+
env: MicrosubEnv,
|
|
145
|
+
config: ResolvedConfig,
|
|
146
|
+
params: URLSearchParams,
|
|
147
|
+
scopes: readonly string[],
|
|
148
|
+
recordReplay: boolean,
|
|
149
|
+
): Promise<AuthResult> {
|
|
150
|
+
const headerToken = tokenFromHeader(request);
|
|
151
|
+
const paramToken = params.get("access_token");
|
|
152
|
+
if (headerToken !== null && paramToken !== null) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: "invalid_request",
|
|
156
|
+
description:
|
|
157
|
+
"the access token must be supplied via exactly one method, not both the `Authorization` header and a parameter",
|
|
158
|
+
status: 400,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const auth = await authorize(
|
|
162
|
+
request,
|
|
163
|
+
env as AuthEnv,
|
|
164
|
+
config,
|
|
165
|
+
headerToken ?? paramToken,
|
|
166
|
+
scopes,
|
|
167
|
+
recordReplay,
|
|
168
|
+
);
|
|
169
|
+
if (!auth.ok) {
|
|
170
|
+
emit(config, "warn", MicrosubLogEvent.AuthRejected, {
|
|
171
|
+
reason: auth.error,
|
|
172
|
+
status: auth.status,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return auth;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function authError(auth: Extract<AuthResult, { ok: false }>): Response {
|
|
179
|
+
return error(auth.error, auth.description, auth.status);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Channels ---------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
async function handleChannelsGet(
|
|
185
|
+
request: Request,
|
|
186
|
+
env: MicrosubEnv,
|
|
187
|
+
config: ResolvedConfig,
|
|
188
|
+
params: URLSearchParams,
|
|
189
|
+
store: MicrosubStore,
|
|
190
|
+
): Promise<Response> {
|
|
191
|
+
const auth = await requireAuth(request, env, config, params, [], false);
|
|
192
|
+
if (!auth.ok) return authError(auth);
|
|
193
|
+
|
|
194
|
+
const channels = await store.listChannels();
|
|
195
|
+
const withUnread = await Promise.all(
|
|
196
|
+
channels.map(async (channel) => ({
|
|
197
|
+
uid: channel.uid,
|
|
198
|
+
name: channel.name,
|
|
199
|
+
unread: await store.unreadCount(channel.uid),
|
|
200
|
+
})),
|
|
201
|
+
);
|
|
202
|
+
return json({ channels: withUnread });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function handleChannelsPost(
|
|
206
|
+
request: Request,
|
|
207
|
+
env: MicrosubEnv,
|
|
208
|
+
config: ResolvedConfig,
|
|
209
|
+
params: URLSearchParams,
|
|
210
|
+
store: MicrosubStore,
|
|
211
|
+
): Promise<Response> {
|
|
212
|
+
const auth = await requireAuth(
|
|
213
|
+
request,
|
|
214
|
+
env,
|
|
215
|
+
config,
|
|
216
|
+
params,
|
|
217
|
+
["channels"],
|
|
218
|
+
true,
|
|
219
|
+
);
|
|
220
|
+
if (!auth.ok) return authError(auth);
|
|
221
|
+
|
|
222
|
+
const method = params.get("method");
|
|
223
|
+
const channel = params.get("channel");
|
|
224
|
+
|
|
225
|
+
if (method === "delete") {
|
|
226
|
+
if (!channel) return rejected(config, "missing_channel");
|
|
227
|
+
if (channel === NOTIFICATIONS_CHANNEL) {
|
|
228
|
+
return rejected(config, "reserved_channel");
|
|
229
|
+
}
|
|
230
|
+
const ok = await store.deleteChannel(channel);
|
|
231
|
+
if (!ok) return error("invalid_request", "no such channel", 404);
|
|
232
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, {
|
|
233
|
+
action: "channels",
|
|
234
|
+
method: "delete",
|
|
235
|
+
});
|
|
236
|
+
return json({});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (method === "order") {
|
|
240
|
+
const order = getAll(params, "channels");
|
|
241
|
+
await store.reorderChannels(order);
|
|
242
|
+
const channels = await store.listChannels();
|
|
243
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, {
|
|
244
|
+
action: "channels",
|
|
245
|
+
method: "order",
|
|
246
|
+
});
|
|
247
|
+
return json({
|
|
248
|
+
channels: channels.map((c) => ({ uid: c.uid, name: c.name })),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const name = params.get("name");
|
|
253
|
+
if (!name) return rejected(config, "missing_name");
|
|
254
|
+
|
|
255
|
+
if (channel) {
|
|
256
|
+
const updated = await store.renameChannel(channel, name);
|
|
257
|
+
if (!updated) {
|
|
258
|
+
return error(
|
|
259
|
+
"invalid_request",
|
|
260
|
+
channel === NOTIFICATIONS_CHANNEL
|
|
261
|
+
? "the notifications channel cannot be renamed"
|
|
262
|
+
: "no such channel",
|
|
263
|
+
channel === NOTIFICATIONS_CHANNEL ? 400 : 404,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, {
|
|
267
|
+
action: "channels",
|
|
268
|
+
method: "update",
|
|
269
|
+
});
|
|
270
|
+
return json({ uid: updated.uid, name: updated.name });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const created = await store.createChannel(name, now());
|
|
274
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, {
|
|
275
|
+
action: "channels",
|
|
276
|
+
method: "create",
|
|
277
|
+
});
|
|
278
|
+
return json({ uid: created.uid, name: created.name });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Following --------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
async function handleFollowGet(
|
|
284
|
+
request: Request,
|
|
285
|
+
env: MicrosubEnv,
|
|
286
|
+
config: ResolvedConfig,
|
|
287
|
+
params: URLSearchParams,
|
|
288
|
+
store: MicrosubStore,
|
|
289
|
+
): Promise<Response> {
|
|
290
|
+
const auth = await requireAuth(request, env, config, params, [], false);
|
|
291
|
+
if (!auth.ok) return authError(auth);
|
|
292
|
+
|
|
293
|
+
const channel = params.get("channel");
|
|
294
|
+
if (!channel) return rejected(config, "missing_channel");
|
|
295
|
+
if (!(await store.channelExists(channel))) {
|
|
296
|
+
return error("invalid_request", "no such channel", 404);
|
|
297
|
+
}
|
|
298
|
+
const follows = await store.listFollows(channel);
|
|
299
|
+
return json({
|
|
300
|
+
items: follows.map((follow) => ({ type: "feed", url: follow.pageUrl })),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function handleFollow(
|
|
305
|
+
request: Request,
|
|
306
|
+
env: MicrosubEnv,
|
|
307
|
+
config: ResolvedConfig,
|
|
308
|
+
params: URLSearchParams,
|
|
309
|
+
store: MicrosubStore,
|
|
310
|
+
): Promise<Response> {
|
|
311
|
+
const auth = await requireAuth(
|
|
312
|
+
request,
|
|
313
|
+
env,
|
|
314
|
+
config,
|
|
315
|
+
params,
|
|
316
|
+
["follow", "channels"],
|
|
317
|
+
true,
|
|
318
|
+
);
|
|
319
|
+
if (!auth.ok) return authError(auth);
|
|
320
|
+
|
|
321
|
+
const channel = params.get("channel");
|
|
322
|
+
const url = params.get("url");
|
|
323
|
+
if (!channel) return rejected(config, "missing_channel");
|
|
324
|
+
if (!url) return rejected(config, "missing_url");
|
|
325
|
+
if (!(await store.channelExists(channel))) {
|
|
326
|
+
return error("invalid_request", "no such channel", 404);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const discovered = await discoverFeed(url, {
|
|
330
|
+
fetch: config.fetch,
|
|
331
|
+
logger: config.logger,
|
|
332
|
+
metrics: config.metrics,
|
|
333
|
+
});
|
|
334
|
+
const feedUrl = discovered?.feedUrl ?? url;
|
|
335
|
+
await store.addFollow(channel, feedUrl, url, now());
|
|
336
|
+
|
|
337
|
+
if (discovered && discovered.entries.length > 0) {
|
|
338
|
+
// Populate the timeline immediately so the user does not wait for the next
|
|
339
|
+
// scheduled poll.
|
|
340
|
+
const ordered = orderEntriesForInsert(discovered.entries);
|
|
341
|
+
await store.insertItems(
|
|
342
|
+
channel,
|
|
343
|
+
feedUrl,
|
|
344
|
+
ordered,
|
|
345
|
+
now(),
|
|
346
|
+
config.maxItemsPerChannel,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
// Prime the poll cache and pick up anything discovery did not.
|
|
350
|
+
await env.MICROSUB_QUEUE.send({ kind: "poll", feedUrl });
|
|
351
|
+
|
|
352
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, { action: "follow" });
|
|
353
|
+
return json({ type: "feed", url });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function handleUnfollow(
|
|
357
|
+
request: Request,
|
|
358
|
+
env: MicrosubEnv,
|
|
359
|
+
config: ResolvedConfig,
|
|
360
|
+
params: URLSearchParams,
|
|
361
|
+
store: MicrosubStore,
|
|
362
|
+
): Promise<Response> {
|
|
363
|
+
const auth = await requireAuth(
|
|
364
|
+
request,
|
|
365
|
+
env,
|
|
366
|
+
config,
|
|
367
|
+
params,
|
|
368
|
+
["follow", "channels"],
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
371
|
+
if (!auth.ok) return authError(auth);
|
|
372
|
+
|
|
373
|
+
const channel = params.get("channel");
|
|
374
|
+
const url = params.get("url");
|
|
375
|
+
if (!channel) return rejected(config, "missing_channel");
|
|
376
|
+
if (!url) return rejected(config, "missing_url");
|
|
377
|
+
await store.removeFollow(channel, url);
|
|
378
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, {
|
|
379
|
+
action: "unfollow",
|
|
380
|
+
});
|
|
381
|
+
return json({});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- Timeline ---------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
async function handleTimelineGet(
|
|
387
|
+
request: Request,
|
|
388
|
+
env: MicrosubEnv,
|
|
389
|
+
config: ResolvedConfig,
|
|
390
|
+
params: URLSearchParams,
|
|
391
|
+
store: MicrosubStore,
|
|
392
|
+
): Promise<Response> {
|
|
393
|
+
const auth = await requireAuth(request, env, config, params, [], false);
|
|
394
|
+
if (!auth.ok) return authError(auth);
|
|
395
|
+
|
|
396
|
+
const channel = params.get("channel");
|
|
397
|
+
if (!channel) return rejected(config, "missing_channel");
|
|
398
|
+
if (!(await store.channelExists(channel))) {
|
|
399
|
+
return error("invalid_request", "no such channel", 404);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const before = params.get("before") ?? undefined;
|
|
403
|
+
const after = params.get("after") ?? undefined;
|
|
404
|
+
const page = await store.listItems(channel, {
|
|
405
|
+
...(before !== undefined ? { before } : {}),
|
|
406
|
+
...(after !== undefined ? { after } : {}),
|
|
407
|
+
limit: config.pageSize,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const paging: { before?: string; after?: string } = {};
|
|
411
|
+
if (page.before !== undefined) paging.before = page.before;
|
|
412
|
+
if (page.after !== undefined) paging.after = page.after;
|
|
413
|
+
return json({ items: page.items.map((item) => item.entry), paging });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function handleTimelinePost(
|
|
417
|
+
request: Request,
|
|
418
|
+
env: MicrosubEnv,
|
|
419
|
+
config: ResolvedConfig,
|
|
420
|
+
params: URLSearchParams,
|
|
421
|
+
store: MicrosubStore,
|
|
422
|
+
): Promise<Response> {
|
|
423
|
+
const auth = await requireAuth(request, env, config, params, ["read"], true);
|
|
424
|
+
if (!auth.ok) return authError(auth);
|
|
425
|
+
|
|
426
|
+
const method = params.get("method");
|
|
427
|
+
const channel = params.get("channel");
|
|
428
|
+
if (!channel) return rejected(config, "missing_channel");
|
|
429
|
+
const entries = getAll(params, "entry");
|
|
430
|
+
const lastRead = params.get("last_read_entry");
|
|
431
|
+
|
|
432
|
+
switch (method) {
|
|
433
|
+
case "mark_read":
|
|
434
|
+
if (lastRead) await store.markReadThrough(channel, lastRead);
|
|
435
|
+
else await store.markRead(channel, entries, true);
|
|
436
|
+
break;
|
|
437
|
+
case "mark_unread":
|
|
438
|
+
await store.markRead(channel, entries, false);
|
|
439
|
+
break;
|
|
440
|
+
case "remove":
|
|
441
|
+
for (const entry of entries) await store.removeItem(channel, entry);
|
|
442
|
+
break;
|
|
443
|
+
default:
|
|
444
|
+
return rejected(config, "unknown_method");
|
|
445
|
+
}
|
|
446
|
+
emit(config, "info", MicrosubLogEvent.ActionCompleted, {
|
|
447
|
+
action: "timeline",
|
|
448
|
+
method: method ?? "",
|
|
449
|
+
});
|
|
450
|
+
return json({});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- Search / preview -------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
async function handlePreview(
|
|
456
|
+
request: Request,
|
|
457
|
+
env: MicrosubEnv,
|
|
458
|
+
config: ResolvedConfig,
|
|
459
|
+
params: URLSearchParams,
|
|
460
|
+
): Promise<Response> {
|
|
461
|
+
const auth = await requireAuth(request, env, config, params, [], false);
|
|
462
|
+
if (!auth.ok) return authError(auth);
|
|
463
|
+
|
|
464
|
+
const url = params.get("url");
|
|
465
|
+
if (!url) return rejected(config, "missing_url");
|
|
466
|
+
const discovered = await discoverFeed(url, {
|
|
467
|
+
fetch: config.fetch,
|
|
468
|
+
logger: config.logger,
|
|
469
|
+
metrics: config.metrics,
|
|
470
|
+
});
|
|
471
|
+
const items = (discovered?.entries ?? []).slice(0, config.pageSize);
|
|
472
|
+
return json({ items });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function handleSearch(
|
|
476
|
+
request: Request,
|
|
477
|
+
env: MicrosubEnv,
|
|
478
|
+
config: ResolvedConfig,
|
|
479
|
+
params: URLSearchParams,
|
|
480
|
+
store: MicrosubStore,
|
|
481
|
+
): Promise<Response> {
|
|
482
|
+
const auth = await requireAuth(request, env, config, params, [], false);
|
|
483
|
+
if (!auth.ok) return authError(auth);
|
|
484
|
+
|
|
485
|
+
const query = (params.get("query") ?? "").trim();
|
|
486
|
+
const channel = params.get("channel");
|
|
487
|
+
|
|
488
|
+
if (channel) {
|
|
489
|
+
if (!(await store.channelExists(channel))) {
|
|
490
|
+
return error("invalid_request", "no such channel", 404);
|
|
491
|
+
}
|
|
492
|
+
const results = await store.searchItems(channel, query, config.pageSize);
|
|
493
|
+
return json({ items: results.map((item) => item.entry) });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (query === "") return rejected(config, "missing_query");
|
|
497
|
+
// Feed search: we have no third-party feed index, so a URL-shaped query is
|
|
498
|
+
// resolved through discovery; anything else returns no results.
|
|
499
|
+
const results: Array<{ type: "feed"; url: string }> = [];
|
|
500
|
+
if (/^https?:\/\//i.test(query)) {
|
|
501
|
+
const discovered = await discoverFeed(query, {
|
|
502
|
+
fetch: config.fetch,
|
|
503
|
+
logger: config.logger,
|
|
504
|
+
metrics: config.metrics,
|
|
505
|
+
});
|
|
506
|
+
if (discovered) results.push({ type: "feed", url: discovered.feedUrl });
|
|
507
|
+
}
|
|
508
|
+
return json({ results });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function rejected(config: ResolvedConfig, reason: string): Response {
|
|
512
|
+
emit(config, "warn", MicrosubLogEvent.RequestRejected, { reason });
|
|
513
|
+
const messages: Record<string, string> = {
|
|
514
|
+
missing_channel: "`channel` is required",
|
|
515
|
+
missing_url: "`url` is required",
|
|
516
|
+
missing_name: "`name` is required",
|
|
517
|
+
missing_query: "`query` is required",
|
|
518
|
+
reserved_channel: "the notifications channel cannot be deleted",
|
|
519
|
+
unknown_method: "unsupported `method`",
|
|
520
|
+
unknown_action: "unsupported `action`",
|
|
521
|
+
};
|
|
522
|
+
const status = reason === "reserved_channel" ? 400 : 400;
|
|
523
|
+
return error(
|
|
524
|
+
"invalid_request",
|
|
525
|
+
messages[reason] ?? "invalid request",
|
|
526
|
+
status,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --- Entry point ------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Create the Microsub handler. The returned handler routes by pathname against
|
|
534
|
+
* the configured endpoint URL, so it is mountable under any path prefix, then
|
|
535
|
+
* dispatches on the `action` (and `method`) parameters.
|
|
536
|
+
*/
|
|
537
|
+
export function createMicrosub(config: MicrosubConfig): MicrosubHandler {
|
|
538
|
+
const resolved = resolveConfig(config);
|
|
539
|
+
|
|
540
|
+
return async (request, env, _ctx) => {
|
|
541
|
+
assertBindings(env);
|
|
542
|
+
const { pathname } = new URL(request.url);
|
|
543
|
+
const method = request.method.toUpperCase();
|
|
544
|
+
|
|
545
|
+
if (method === "OPTIONS") {
|
|
546
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
547
|
+
}
|
|
548
|
+
if (pathname !== resolved.microsubPath) {
|
|
549
|
+
return new Response("Not Found", { status: 404 });
|
|
550
|
+
}
|
|
551
|
+
if (method !== "GET" && method !== "POST") {
|
|
552
|
+
return new Response("Method Not Allowed", {
|
|
553
|
+
status: 405,
|
|
554
|
+
headers: { allow: "GET, POST, OPTIONS", ...CORS_HEADERS },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const params = await readParams(request, method);
|
|
559
|
+
const action = params.get("action") ?? "";
|
|
560
|
+
const store = createMicrosubStore(env);
|
|
561
|
+
|
|
562
|
+
if (method === "GET") {
|
|
563
|
+
switch (action) {
|
|
564
|
+
case "channels":
|
|
565
|
+
return handleChannelsGet(request, env, resolved, params, store);
|
|
566
|
+
case "timeline":
|
|
567
|
+
return handleTimelineGet(request, env, resolved, params, store);
|
|
568
|
+
case "follow":
|
|
569
|
+
return handleFollowGet(request, env, resolved, params, store);
|
|
570
|
+
case "preview":
|
|
571
|
+
return handlePreview(request, env, resolved, params);
|
|
572
|
+
case "search":
|
|
573
|
+
return handleSearch(request, env, resolved, params, store);
|
|
574
|
+
default:
|
|
575
|
+
return rejected(resolved, "unknown_action");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
switch (action) {
|
|
580
|
+
case "channels":
|
|
581
|
+
return handleChannelsPost(request, env, resolved, params, store);
|
|
582
|
+
case "follow":
|
|
583
|
+
return handleFollow(request, env, resolved, params, store);
|
|
584
|
+
case "unfollow":
|
|
585
|
+
return handleUnfollow(request, env, resolved, params, store);
|
|
586
|
+
case "timeline":
|
|
587
|
+
return handleTimelinePost(request, env, resolved, params, store);
|
|
588
|
+
case "search":
|
|
589
|
+
return handleSearch(request, env, resolved, params, store);
|
|
590
|
+
default:
|
|
591
|
+
return rejected(resolved, "unknown_action");
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|