@sdxc/activitypub 0.0.0-pre.1
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.md +21 -0
- package/README.md +711 -0
- package/dist/collections.d.ts +67 -0
- package/dist/collections.js +38 -0
- package/dist/conformance.d.ts +80 -0
- package/dist/conformance.js +313 -0
- package/dist/discovery.d.ts +46 -0
- package/dist/discovery.js +105 -0
- package/dist/errors.d.ts +115 -0
- package/dist/errors.js +124 -0
- package/dist/federation.d.ts +281 -0
- package/dist/federation.js +513 -0
- package/dist/fixtures/gotosocial.d.ts +114 -0
- package/dist/fixtures/gotosocial.js +115 -0
- package/dist/fixtures/index.d.ts +15 -0
- package/dist/fixtures/index.js +50 -0
- package/dist/fixtures/mastodon.d.ts +489 -0
- package/dist/fixtures/mastodon.js +334 -0
- package/dist/fixtures/misskey.d.ts +254 -0
- package/dist/fixtures/misskey.js +163 -0
- package/dist/fixtures/pixelfed.d.ts +118 -0
- package/dist/fixtures/pixelfed.js +126 -0
- package/dist/fixtures/shared.d.ts +14 -0
- package/dist/fixtures/shared.js +14 -0
- package/dist/fixtures/threads.d.ts +82 -0
- package/dist/fixtures/threads.js +88 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +22 -0
- package/dist/keys.d.ts +100 -0
- package/dist/keys.js +147 -0
- package/dist/lib/availability.d.ts +37 -0
- package/dist/lib/availability.js +52 -0
- package/dist/lib/compact.d.ts +145 -0
- package/dist/lib/compact.js +514 -0
- package/dist/lib/constants.d.ts +35 -0
- package/dist/lib/constants.js +65 -0
- package/dist/lib/delivery-fan-out.d.ts +72 -0
- package/dist/lib/delivery-fan-out.js +202 -0
- package/dist/lib/delivery-post.d.ts +108 -0
- package/dist/lib/delivery-post.js +318 -0
- package/dist/lib/fetch.d.ts +64 -0
- package/dist/lib/fetch.js +103 -0
- package/dist/lib/inbox-error.d.ts +45 -0
- package/dist/lib/inbox-error.js +74 -0
- package/dist/lib/inbox-handle.d.ts +119 -0
- package/dist/lib/inbox-handle.js +587 -0
- package/dist/lib/inbox-receive.d.ts +117 -0
- package/dist/lib/inbox-receive.js +233 -0
- package/dist/lib/inbox-signature.d.ts +61 -0
- package/dist/lib/inbox-signature.js +156 -0
- package/dist/lib/inbox-summarize.d.ts +41 -0
- package/dist/lib/inbox-summarize.js +97 -0
- package/dist/lib/messages.d.ts +36 -0
- package/dist/lib/messages.js +35 -0
- package/dist/lib/parse.d.ts +42 -0
- package/dist/lib/parse.js +348 -0
- package/dist/lib/response.d.ts +45 -0
- package/dist/lib/response.js +59 -0
- package/dist/lib/stringify.d.ts +51 -0
- package/dist/lib/stringify.js +122 -0
- package/dist/lib/tombstone.d.ts +24 -0
- package/dist/lib/tombstone.js +15 -0
- package/dist/lib/types.d.ts +249 -0
- package/dist/lib/types.js +0 -0
- package/dist/memory.d.ts +77 -0
- package/dist/memory.js +146 -0
- package/dist/remote.d.ts +105 -0
- package/dist/remote.js +262 -0
- package/dist/store.d.ts +78 -0
- package/dist/store.js +8 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +10 -0
- package/package.json +54 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { currentLog } from "@sdxc/logger";
|
|
2
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
3
|
+
import { orderedCollection, orderedCollectionPage } from "./collections.js";
|
|
4
|
+
import { lookup } from "./discovery.js";
|
|
5
|
+
import { ActivityPubError, FederationError } from "./errors.js";
|
|
6
|
+
import { fanOut, MAX_ACTIVITY_BYTES } from "./lib/delivery-fan-out.js";
|
|
7
|
+
import { deliver, DELIVERY_BACKOFF } from "./lib/delivery-post.js";
|
|
8
|
+
import { handle } from "./lib/inbox-handle.js";
|
|
9
|
+
import { receive, verifyFetch } from "./lib/inbox-receive.js";
|
|
10
|
+
import { summarize } from "./lib/inbox-summarize.js";
|
|
11
|
+
import { MESSAGE } from "./lib/messages.js";
|
|
12
|
+
import { respond, wantsActivity } from "./lib/response.js";
|
|
13
|
+
import { stringify } from "./lib/stringify.js";
|
|
14
|
+
import { RemoteResolver } from "./remote.js";
|
|
15
|
+
/** Followers listed per page of the followers collection. */
|
|
16
|
+
const DEFAULT_COLLECTION_PAGE_SIZE = 50;
|
|
17
|
+
/** The handler key of `handle` each registrable activity type reaches. */
|
|
18
|
+
const HANDLER_KEYS = {
|
|
19
|
+
Follow: "follow",
|
|
20
|
+
Undo: "undo",
|
|
21
|
+
Create: "create",
|
|
22
|
+
Update: "update",
|
|
23
|
+
Delete: "delete",
|
|
24
|
+
Like: "like",
|
|
25
|
+
Announce: "announce",
|
|
26
|
+
Accept: "accept",
|
|
27
|
+
Reject: "reject",
|
|
28
|
+
Move: "move",
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Federates one local actor. Construct it wherever the stores are at hand (per request or
|
|
32
|
+
* per job is fine: it holds no state beyond its options and handlers), route the actor's
|
|
33
|
+
* URLs through `fetch`, and hand every queued message to `process`.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* let federation = new Federation({ actor, keys, stores, cache, userAgent, queue });
|
|
37
|
+
* federation.on("Create", async (ctx) => saveReply(ctx.summary()));
|
|
38
|
+
*/
|
|
39
|
+
export class Federation {
|
|
40
|
+
/**
|
|
41
|
+
* The Standard Schema of every message federation enqueues (`inbox`, `fanOut` and
|
|
42
|
+
* `deliver`, told apart by `kind`), so one job declared with it carries all three.
|
|
43
|
+
*
|
|
44
|
+
* @example federation: job({ input: Federation.MESSAGE })
|
|
45
|
+
*/
|
|
46
|
+
static MESSAGE = MESSAGE;
|
|
47
|
+
/**
|
|
48
|
+
* Fetches remote actors, keys and objects through the app's cache, signing every GET
|
|
49
|
+
* with the local actor's keys unless `resolver.signed` is `false`.
|
|
50
|
+
*/
|
|
51
|
+
resolver;
|
|
52
|
+
#options;
|
|
53
|
+
#keys;
|
|
54
|
+
#blocked;
|
|
55
|
+
#backoff;
|
|
56
|
+
#now;
|
|
57
|
+
#handlers = new Map();
|
|
58
|
+
/** @param options - The local actor, its keys, the stores, the cache and the queue. */
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.#options = options;
|
|
61
|
+
this.#keys = providerOf(options.keys);
|
|
62
|
+
this.#blocked = options.blocked ?? (() => false);
|
|
63
|
+
this.#backoff = options.backoff ?? DELIVERY_BACKOFF;
|
|
64
|
+
this.#now = options.now ?? (() => new Date());
|
|
65
|
+
this.resolver = new RemoteResolver({
|
|
66
|
+
cache: options.cache,
|
|
67
|
+
userAgent: options.userAgent,
|
|
68
|
+
...(options.resolver?.signed === false
|
|
69
|
+
? {}
|
|
70
|
+
: { signer: { actor: options.actor.id, keys: options.keys } }),
|
|
71
|
+
...(options.resolver?.ttl === undefined ? {} : { ttl: options.resolver.ttl }),
|
|
72
|
+
...(options.resolver?.timeout === undefined ? {} : { timeout: options.resolver.timeout }),
|
|
73
|
+
...(options.resolver?.maxBytes === undefined ? {} : { maxBytes: options.resolver.maxBytes }),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Registers the app's handler for one or more activity types, called once the package
|
|
78
|
+
* has done the work the protocol requires (followers kept, Accept sent, ownership and
|
|
79
|
+
* relevance checked). A later registration for a type replaces the earlier one.
|
|
80
|
+
*
|
|
81
|
+
* A handler answers nothing or a `Result`; a failure, or an exception, retries the
|
|
82
|
+
* message, so a handler tolerates seeing an activity twice. A `Follow` handler may
|
|
83
|
+
* answer `"accept"`, `"reject"` or `"pending"`. `Like` handlers also receive Pleroma's
|
|
84
|
+
* `EmojiReact`.
|
|
85
|
+
*
|
|
86
|
+
* @param type - The activity type, or several sharing one handler.
|
|
87
|
+
* @param handler - Receives the verified activity and its `summary()`.
|
|
88
|
+
* @returns The federation, so registrations chain.
|
|
89
|
+
* @example federation.on(["Like", "Announce"], async (ctx) => saveResponse(ctx.summary()));
|
|
90
|
+
*/
|
|
91
|
+
on(type, handler) {
|
|
92
|
+
let types = typeof type === "string" ? [type] : type;
|
|
93
|
+
for (let one of types)
|
|
94
|
+
this.#handlers.set(one, handler);
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Answers the local actor's own URLs, as its document names them: the actor (with
|
|
99
|
+
* `publicKey` from its keys), a `POST` to `inbox` or `endpoints.sharedInbox`, `outbox`,
|
|
100
|
+
* `followers` and `following`. Any other URL answers `null`, so the app routes it on.
|
|
101
|
+
*
|
|
102
|
+
* An inbox `POST` is verified here, inside the request, and answers `202` once its
|
|
103
|
+
* message is queued, or the status of the check it failed. With `authorizedFetch`, the
|
|
104
|
+
* collections require a signed GET; the actor document always answers unsigned.
|
|
105
|
+
*
|
|
106
|
+
* @param request - Any incoming request.
|
|
107
|
+
* @returns The response, or `null` for a URL that is not one of the actor's.
|
|
108
|
+
* @example let response = await federation.fetch(request); if (response) return response;
|
|
109
|
+
*/
|
|
110
|
+
async fetch(request) {
|
|
111
|
+
let actor = this.#options.actor;
|
|
112
|
+
let path = pathOf(request.url);
|
|
113
|
+
if (path === null)
|
|
114
|
+
return null;
|
|
115
|
+
let method = request.method.toUpperCase();
|
|
116
|
+
let reads = method === "GET" || method === "HEAD";
|
|
117
|
+
let isInbox = path === pathOf(actor.inbox) || path === pathOf(actor.endpoints.sharedInbox);
|
|
118
|
+
if (isInbox && method === "POST")
|
|
119
|
+
return this.#receive(request);
|
|
120
|
+
let route = this.#documentRoute(path);
|
|
121
|
+
if (route !== null && reads) {
|
|
122
|
+
if (route !== "actor" && this.#options.authorizedFetch === true) {
|
|
123
|
+
let verified = await this.verify(request);
|
|
124
|
+
if (isFailure(verified))
|
|
125
|
+
return verified.error.toResponse();
|
|
126
|
+
}
|
|
127
|
+
return this.#serve(route, request);
|
|
128
|
+
}
|
|
129
|
+
if (isInbox)
|
|
130
|
+
return notAllowed("POST");
|
|
131
|
+
if (route !== null)
|
|
132
|
+
return notAllowed("GET, HEAD");
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Answers `document` as ActivityStreams when the request prefers it over HTML, for a
|
|
137
|
+
* URL that serves both, such as a post's page. The response carries `Vary: Accept`, an
|
|
138
|
+
* `ETag` with `304` revalidation, a public 5-minute cache unless stated, and `410` for a
|
|
139
|
+
* `Tombstone`. With `authorizedFetch`, the request must carry a valid signature.
|
|
140
|
+
*
|
|
141
|
+
* @param request - The incoming request.
|
|
142
|
+
* @param document - The object, actor or collection the URL names.
|
|
143
|
+
* @param options - The cache policy, extra headers and status.
|
|
144
|
+
* @returns The response, or `null` when HTML wins and the app renders its page.
|
|
145
|
+
* @example let response = await federation.respond(request, article); if (response) return response;
|
|
146
|
+
*/
|
|
147
|
+
async respond(request, document, options = {}) {
|
|
148
|
+
if (!wantsActivity(request))
|
|
149
|
+
return null;
|
|
150
|
+
if (this.#options.authorizedFetch === true) {
|
|
151
|
+
let verified = await this.verify(request);
|
|
152
|
+
if (isFailure(verified))
|
|
153
|
+
return verified.error.toResponse();
|
|
154
|
+
}
|
|
155
|
+
return respond(document, { ...options, request, vary: true });
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Verifies the signature on a GET (authorized fetch): the blocked host of the key, the
|
|
159
|
+
* signature's age, and the key, refetched once when it no longer verifies. The local
|
|
160
|
+
* actor's document passes unsigned, because remote servers read its key from there.
|
|
161
|
+
*
|
|
162
|
+
* @param request - The GET.
|
|
163
|
+
* @returns The signer, or an `InboxError` whose `toResponse()` refuses the request.
|
|
164
|
+
* @example let verified = await federation.verify(request); if (isFailure(verified)) return verified.error.toResponse();
|
|
165
|
+
*/
|
|
166
|
+
verify(request) {
|
|
167
|
+
let maxAge = this.#options.inbox?.maxAge;
|
|
168
|
+
return verifyFetch(request, {
|
|
169
|
+
resolver: this.resolver,
|
|
170
|
+
blocked: this.#blocked,
|
|
171
|
+
actors: [this.#options.actor.id],
|
|
172
|
+
now: this.#now(),
|
|
173
|
+
...(maxAge === undefined ? {} : { maxAge }),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Runs one queued message: an inbox activity through the protocol's checks and the
|
|
178
|
+
* app's handlers, a fan-out into one `deliver` message per distinct inbox, or one signed
|
|
179
|
+
* delivery. Accept and Reject replies are queued as deliveries, and an inbox answering
|
|
180
|
+
* `410` drops every follower reached through it.
|
|
181
|
+
*
|
|
182
|
+
* @param message - What the queue handed back, read through `Federation.MESSAGE`.
|
|
183
|
+
* @param options - The job's attempt, starting at 1, which spaces retries.
|
|
184
|
+
* @returns What happened, or a `FederationError` whose `retryable` and `delay` say
|
|
185
|
+
* whether and when to retry.
|
|
186
|
+
* @example
|
|
187
|
+
* let processed = await federation.process(ctx.input, { attempts: ctx.attempts });
|
|
188
|
+
* if (isFailure(processed) && processed.error.retryable) ctx.retry({ delay: processed.error.delay });
|
|
189
|
+
*/
|
|
190
|
+
async process(message, options = {}) {
|
|
191
|
+
let attempts = options.attempts ?? 1;
|
|
192
|
+
if (message.kind === "inbox")
|
|
193
|
+
return this.#processInbox(message, attempts);
|
|
194
|
+
if (message.kind === "fanOut")
|
|
195
|
+
return this.#processFanOut(message, attempts);
|
|
196
|
+
return this.#processDelivery(message, attempts);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Queues the fan-out of an activity by the local actor to everyone it addresses: its
|
|
200
|
+
* followers when the followers collection is in `to` or `cc`, and every other addressed
|
|
201
|
+
* actor's inbox.
|
|
202
|
+
*
|
|
203
|
+
* @param activity - The activity, as a document or as JSON text already serialized.
|
|
204
|
+
* @returns `too-large` for an activity over 120 KB, which no delivery message can carry,
|
|
205
|
+
* or a retryable `enqueue` failure.
|
|
206
|
+
* @example await federation.publish({ id: `${post.url}#create`, type: "Create", actor: ACTOR_ID, object: article, to: [PUBLIC], cc: [FOLLOWERS_ID] });
|
|
207
|
+
*/
|
|
208
|
+
async publish(activity) {
|
|
209
|
+
let text = typeof activity === "string" ? activity : stringify(activity);
|
|
210
|
+
let size = new TextEncoder().encode(text).byteLength;
|
|
211
|
+
if (size > MAX_ACTIVITY_BYTES) {
|
|
212
|
+
return failure(new ActivityPubError("too-large", `The activity is ${size} bytes, over the ${MAX_ACTIVITY_BYTES} a delivery can carry`));
|
|
213
|
+
}
|
|
214
|
+
return this.#enqueue([{ kind: "fanOut", actor: this.#options.actor.id, activity: text }]);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Resolves a handle to its actor the way Mastodon does: WebFinger on the handle's host,
|
|
218
|
+
* the `self` link typed as ActivityStreams, then the actor, whose own host must confirm
|
|
219
|
+
* the handle when it differs from the actor's canonical one.
|
|
220
|
+
*
|
|
221
|
+
* @param handle - `@user@host`, `user@host` or `acct:user@host`.
|
|
222
|
+
* @returns The actor; `not-found` when WebFinger names no actor, `id-mismatch` when the
|
|
223
|
+
* actor's host does not confirm the handle.
|
|
224
|
+
* @example let actor = await federation.lookup("@someone@mastodon.social");
|
|
225
|
+
*/
|
|
226
|
+
lookup(handle) {
|
|
227
|
+
let timeout = this.#options.resolver?.timeout;
|
|
228
|
+
return lookup(handle, {
|
|
229
|
+
resolver: this.resolver,
|
|
230
|
+
userAgent: this.#options.userAgent,
|
|
231
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/** Verifies an inbox POST and queues it; the status says what the sender does next. */
|
|
235
|
+
async #receive(request) {
|
|
236
|
+
let inbox = this.#options.inbox;
|
|
237
|
+
let received = await receive(request, {
|
|
238
|
+
resolver: this.resolver,
|
|
239
|
+
blocked: this.#blocked,
|
|
240
|
+
cache: this.#options.cache,
|
|
241
|
+
now: this.#now(),
|
|
242
|
+
...(inbox?.maxBytes === undefined ? {} : { maxBytes: inbox.maxBytes }),
|
|
243
|
+
...(inbox?.maxAge === undefined ? {} : { maxAge: inbox.maxAge }),
|
|
244
|
+
});
|
|
245
|
+
if (isFailure(received)) {
|
|
246
|
+
currentLog()?.note("activitypub.inbox.refused", {
|
|
247
|
+
code: received.error.code,
|
|
248
|
+
reason: received.error.message,
|
|
249
|
+
});
|
|
250
|
+
return received.error.toResponse();
|
|
251
|
+
}
|
|
252
|
+
let queued = await this.#enqueue([{ kind: "inbox", ...received.data }]);
|
|
253
|
+
if (isFailure(queued)) {
|
|
254
|
+
currentLog()?.warn("activitypub.inbox.unqueued", { reason: queued.error.message });
|
|
255
|
+
return unavailable();
|
|
256
|
+
}
|
|
257
|
+
return new Response(null, { status: 202 });
|
|
258
|
+
}
|
|
259
|
+
/** Which of the actor's documents `path` names, or `null` for none. */
|
|
260
|
+
#documentRoute(path) {
|
|
261
|
+
let actor = this.#options.actor;
|
|
262
|
+
if (path === pathOf(actor.id))
|
|
263
|
+
return "actor";
|
|
264
|
+
if (path === pathOf(actor.outbox))
|
|
265
|
+
return "outbox";
|
|
266
|
+
if (path === pathOf(actor.followers))
|
|
267
|
+
return "followers";
|
|
268
|
+
if (path === pathOf(actor.following))
|
|
269
|
+
return "following";
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
/** Answers one of the actor's documents; a failing store answers `503`. */
|
|
273
|
+
async #serve(route, request) {
|
|
274
|
+
let document = await this.#document(route, new URL(request.url));
|
|
275
|
+
if (isFailure(document)) {
|
|
276
|
+
currentLog()?.warn("activitypub.document.unavailable", {
|
|
277
|
+
route,
|
|
278
|
+
reason: document.error.message,
|
|
279
|
+
});
|
|
280
|
+
return unavailable();
|
|
281
|
+
}
|
|
282
|
+
return respond(document.data, { request });
|
|
283
|
+
}
|
|
284
|
+
/** The document a route answers with, built from the actor, its keys and the stores. */
|
|
285
|
+
async #document(route, url) {
|
|
286
|
+
let actor = this.#options.actor;
|
|
287
|
+
if (route === "actor") {
|
|
288
|
+
let keys = await this.#keys.keysOf(actor.id);
|
|
289
|
+
let publicKey = !isFailure(keys) && keys.data !== null ? keys.data.publicKey : actor.publicKey;
|
|
290
|
+
return success({ ...actor, publicKey });
|
|
291
|
+
}
|
|
292
|
+
let id = url.origin + url.pathname;
|
|
293
|
+
let paged = url.searchParams.has("page") || url.searchParams.has("cursor");
|
|
294
|
+
let cursor = url.searchParams.get("cursor");
|
|
295
|
+
if (route === "following")
|
|
296
|
+
return success(orderedCollection({ id, totalItems: 0 }));
|
|
297
|
+
if (route === "followers") {
|
|
298
|
+
if (!paged) {
|
|
299
|
+
let count = await this.#options.stores.followers.count(actor.id);
|
|
300
|
+
if (isFailure(count))
|
|
301
|
+
return count;
|
|
302
|
+
return success(orderedCollection({ id, totalItems: count.data, first: pageUrl(id, null) }));
|
|
303
|
+
}
|
|
304
|
+
let listed = await this.#options.stores.followers.list(actor.id, {
|
|
305
|
+
cursor,
|
|
306
|
+
limit: this.#options.collections?.pageSize ?? DEFAULT_COLLECTION_PAGE_SIZE,
|
|
307
|
+
});
|
|
308
|
+
if (isFailure(listed))
|
|
309
|
+
return listed;
|
|
310
|
+
return success(orderedCollectionPage({
|
|
311
|
+
id: pageUrl(id, cursor),
|
|
312
|
+
partOf: id,
|
|
313
|
+
orderedItems: listed.data.items.map((follower) => follower.id),
|
|
314
|
+
next: listed.data.next === null ? null : pageUrl(id, listed.data.next),
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
let outbox = this.#options.outbox;
|
|
318
|
+
if (outbox === undefined)
|
|
319
|
+
return success(orderedCollection({ id, totalItems: 0 }));
|
|
320
|
+
let page = await outbox(paged ? cursor : null);
|
|
321
|
+
if (isFailure(page))
|
|
322
|
+
return page;
|
|
323
|
+
if (!paged) {
|
|
324
|
+
return success(orderedCollection({
|
|
325
|
+
id,
|
|
326
|
+
totalItems: page.data.totalItems ?? null,
|
|
327
|
+
first: pageUrl(id, null),
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
return success(orderedCollectionPage({
|
|
331
|
+
id: pageUrl(id, cursor),
|
|
332
|
+
partOf: id,
|
|
333
|
+
orderedItems: page.data.items,
|
|
334
|
+
next: page.data.next === null ? null : pageUrl(id, page.data.next),
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
/** Runs an inbox activity through `handle` with the app's handlers. */
|
|
338
|
+
async #processInbox(message, attempts) {
|
|
339
|
+
let stores = this.#options.stores;
|
|
340
|
+
let handled = await handle(message, {
|
|
341
|
+
actor: this.#options.actor,
|
|
342
|
+
keys: this.#keys,
|
|
343
|
+
followers: stores.followers,
|
|
344
|
+
seen: stores.seen,
|
|
345
|
+
objects: stores.objects,
|
|
346
|
+
resolver: this.resolver,
|
|
347
|
+
blocked: this.#blocked,
|
|
348
|
+
send: (activity, inbox) => this.#enqueue([{ kind: "deliver", actor: this.#options.actor.id, activity, inbox }]),
|
|
349
|
+
on: this.#handlerTable(),
|
|
350
|
+
attempts,
|
|
351
|
+
...(this.#options.seenTtl === undefined ? {} : { seenTtl: this.#options.seenTtl }),
|
|
352
|
+
});
|
|
353
|
+
if (isFailure(handled))
|
|
354
|
+
return this.#failed("inbox", handled.error, attempts, null);
|
|
355
|
+
return success({ kind: "inbox", ...handled.data });
|
|
356
|
+
}
|
|
357
|
+
/** Turns a published activity into one `deliver` message per distinct inbox. */
|
|
358
|
+
async #processFanOut(message, attempts) {
|
|
359
|
+
let pageSize = this.#options.delivery?.pageSize;
|
|
360
|
+
let planned = await fanOut({ actor: message.actor, activity: message.activity }, {
|
|
361
|
+
actor: this.#options.actor,
|
|
362
|
+
followers: this.#options.stores.followers,
|
|
363
|
+
resolver: this.resolver,
|
|
364
|
+
blocked: this.#blocked,
|
|
365
|
+
cache: this.#options.cache,
|
|
366
|
+
enqueue: (deliveries) => this.#enqueue(deliveries.map((delivery) => ({ kind: "deliver", ...delivery }))),
|
|
367
|
+
now: this.#now,
|
|
368
|
+
...(pageSize === undefined ? {} : { pageSize }),
|
|
369
|
+
});
|
|
370
|
+
if (isFailure(planned))
|
|
371
|
+
return this.#failed("fanOut", planned.error, attempts, null);
|
|
372
|
+
return success({ kind: "fanOut", ...planned.data });
|
|
373
|
+
}
|
|
374
|
+
/** Signs and POSTs one delivery, dropping the followers behind an inbox that is gone. */
|
|
375
|
+
async #processDelivery(message, attempts) {
|
|
376
|
+
let timeout = this.#options.delivery?.timeout;
|
|
377
|
+
let sent = await deliver({ actor: message.actor, activity: message.activity, inbox: message.inbox }, {
|
|
378
|
+
keys: this.#keys,
|
|
379
|
+
cache: this.#options.cache,
|
|
380
|
+
userAgent: this.#options.userAgent,
|
|
381
|
+
now: this.#now,
|
|
382
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
383
|
+
});
|
|
384
|
+
if (!isFailure(sent))
|
|
385
|
+
return success({ kind: "deliver", ...sent.data });
|
|
386
|
+
if (sent.error.code === "gone") {
|
|
387
|
+
let removed = await this.#options.stores.followers.removeInbox(message.inbox);
|
|
388
|
+
if (isFailure(removed)) {
|
|
389
|
+
let error = new ActivityPubError("store", removed.error.message, {
|
|
390
|
+
retryable: true,
|
|
391
|
+
cause: removed.error,
|
|
392
|
+
});
|
|
393
|
+
return this.#failed("deliver", error, attempts, null);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return this.#failed("deliver", sent.error, attempts, sent.error.retryAfter);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* A failed message, with the wait before its retry: the backoff step for this attempt,
|
|
400
|
+
* or the inbox's `Retry-After` when that is longer.
|
|
401
|
+
*/
|
|
402
|
+
#failed(kind, error, attempts, retryAfter) {
|
|
403
|
+
let delay = Math.max(retryAfter ?? 0, this.#backoff.delay(attempts));
|
|
404
|
+
return failure(new FederationError(kind, error, delay));
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Writes messages to the app's queue, through `enqueueMany` when it has one. A failure,
|
|
408
|
+
* or an exception, answers a retryable `enqueue` error.
|
|
409
|
+
*/
|
|
410
|
+
async #enqueue(messages) {
|
|
411
|
+
let queue = this.#options.queue;
|
|
412
|
+
let written = undefined;
|
|
413
|
+
try {
|
|
414
|
+
if (queue.enqueueMany !== undefined && messages.length > 1) {
|
|
415
|
+
written = await queue.enqueueMany(messages);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
for (let message of messages) {
|
|
419
|
+
written = await queue.enqueue(message);
|
|
420
|
+
if (isResult(written) && isFailure(written))
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (cause) {
|
|
426
|
+
return failure(enqueueFailed(cause));
|
|
427
|
+
}
|
|
428
|
+
if (isResult(written) && isFailure(written))
|
|
429
|
+
return failure(enqueueFailed(written.error));
|
|
430
|
+
return success(undefined);
|
|
431
|
+
}
|
|
432
|
+
/** The registered handlers in the shape `handle` calls, each given its context. */
|
|
433
|
+
#handlerTable() {
|
|
434
|
+
let table = {};
|
|
435
|
+
for (let [type, handler] of this.#handlers) {
|
|
436
|
+
let call = (inbound) => invoke(handler, contextOf(inbound));
|
|
437
|
+
Object.assign(table, { [HANDLER_KEYS[type]]: call });
|
|
438
|
+
}
|
|
439
|
+
return table;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** The provider every internal step reads keys through, built from a single actor's keys. */
|
|
443
|
+
function providerOf(keys) {
|
|
444
|
+
if ("keysOf" in keys)
|
|
445
|
+
return keys;
|
|
446
|
+
return { keysOf: async (actor) => success(actor === keys.actor ? keys : null) };
|
|
447
|
+
}
|
|
448
|
+
/** The origin and path an IRI names, which is what a request URL is routed by. */
|
|
449
|
+
function pathOf(iri) {
|
|
450
|
+
if (iri === null)
|
|
451
|
+
return null;
|
|
452
|
+
let url = URL.parse(iri);
|
|
453
|
+
return url === null ? null : url.origin + url.pathname;
|
|
454
|
+
}
|
|
455
|
+
/** The URL of a collection page: `?page=true`, plus the store's cursor past the first. */
|
|
456
|
+
function pageUrl(id, cursor) {
|
|
457
|
+
let url = new URL(id);
|
|
458
|
+
url.searchParams.set("page", "true");
|
|
459
|
+
if (cursor !== null)
|
|
460
|
+
url.searchParams.set("cursor", cursor);
|
|
461
|
+
return url.href;
|
|
462
|
+
}
|
|
463
|
+
/** The answer to a method the URL does not serve. */
|
|
464
|
+
function notAllowed(allow) {
|
|
465
|
+
return new Response(null, { status: 405, headers: { allow } });
|
|
466
|
+
}
|
|
467
|
+
/** The answer when a store or the queue failed, which a sender or crawler retries. */
|
|
468
|
+
function unavailable() {
|
|
469
|
+
return new Response("The server could not complete the request.", {
|
|
470
|
+
status: 503,
|
|
471
|
+
headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60" },
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/** Whether a value is a `Result`, which handlers and queues may answer instead of nothing. */
|
|
475
|
+
function isResult(value) {
|
|
476
|
+
if (typeof value !== "object" || value === null || !("status" in value))
|
|
477
|
+
return false;
|
|
478
|
+
return ((value.status === "success" && "data" in value) ||
|
|
479
|
+
(value.status === "failure" && "error" in value));
|
|
480
|
+
}
|
|
481
|
+
/** A retryable `enqueue` failure wrapping what the queue raised. */
|
|
482
|
+
function enqueueFailed(cause) {
|
|
483
|
+
let reason = cause instanceof Error ? cause.message : String(cause);
|
|
484
|
+
return new ActivityPubError("enqueue", `Could not enqueue: ${reason}`, {
|
|
485
|
+
retryable: true,
|
|
486
|
+
cause,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The context a handler receives: the inbound activity and its summary. A `Delete`
|
|
491
|
+
* summarizes as nothing.
|
|
492
|
+
*/
|
|
493
|
+
function contextOf(inbound) {
|
|
494
|
+
if ("deleted" in inbound)
|
|
495
|
+
return { ...inbound, summary: () => null };
|
|
496
|
+
return { ...inbound, summary: () => summarize(inbound) };
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Calls a handler and reads its answer as a `Result`: a plain value succeeds and an
|
|
500
|
+
* exception fails, which `handle` retries like any handler failure.
|
|
501
|
+
*/
|
|
502
|
+
async function invoke(handler, ctx) {
|
|
503
|
+
let returned;
|
|
504
|
+
try {
|
|
505
|
+
returned = await handler(ctx);
|
|
506
|
+
}
|
|
507
|
+
catch (cause) {
|
|
508
|
+
return failure(cause instanceof Error ? cause : new Error(String(cause)));
|
|
509
|
+
}
|
|
510
|
+
if (isResult(returned))
|
|
511
|
+
return returned;
|
|
512
|
+
return success(returned ?? null);
|
|
513
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** A GoToSocial account that approves followers by hand. */
|
|
2
|
+
export declare const GOTOSOCIAL_ACTOR: {
|
|
3
|
+
"@context": (string | {
|
|
4
|
+
discoverable: string;
|
|
5
|
+
featured: {
|
|
6
|
+
"@id": string;
|
|
7
|
+
"@type": string;
|
|
8
|
+
};
|
|
9
|
+
manuallyApprovesFollowers: string;
|
|
10
|
+
toot: string;
|
|
11
|
+
})[];
|
|
12
|
+
discoverable: boolean;
|
|
13
|
+
endpoints: {
|
|
14
|
+
sharedInbox: string;
|
|
15
|
+
};
|
|
16
|
+
featured: string;
|
|
17
|
+
followers: string;
|
|
18
|
+
following: string;
|
|
19
|
+
icon: {
|
|
20
|
+
mediaType: string;
|
|
21
|
+
type: string;
|
|
22
|
+
url: string;
|
|
23
|
+
};
|
|
24
|
+
id: string;
|
|
25
|
+
inbox: string;
|
|
26
|
+
manuallyApprovesFollowers: boolean;
|
|
27
|
+
name: string;
|
|
28
|
+
outbox: string;
|
|
29
|
+
preferredUsername: string;
|
|
30
|
+
publicKey: {
|
|
31
|
+
id: string;
|
|
32
|
+
owner: string;
|
|
33
|
+
publicKeyPem: string;
|
|
34
|
+
};
|
|
35
|
+
published: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
tag: never[];
|
|
38
|
+
type: string;
|
|
39
|
+
url: string;
|
|
40
|
+
};
|
|
41
|
+
/** A reply addressed to one audience entry each, written as plain values. */
|
|
42
|
+
export declare const GOTOSOCIAL_CREATE_NOTE: {
|
|
43
|
+
"@context": (string | {
|
|
44
|
+
sensitive: string;
|
|
45
|
+
})[];
|
|
46
|
+
actor: string;
|
|
47
|
+
cc: string;
|
|
48
|
+
id: string;
|
|
49
|
+
object: {
|
|
50
|
+
attachment: never[];
|
|
51
|
+
attributedTo: string;
|
|
52
|
+
cc: string[];
|
|
53
|
+
content: string;
|
|
54
|
+
contentMap: {
|
|
55
|
+
en: string;
|
|
56
|
+
};
|
|
57
|
+
id: string;
|
|
58
|
+
inReplyTo: string;
|
|
59
|
+
interactionPolicy: {
|
|
60
|
+
canAnnounce: {
|
|
61
|
+
always: string[];
|
|
62
|
+
approvalRequired: never[];
|
|
63
|
+
};
|
|
64
|
+
canLike: {
|
|
65
|
+
always: string[];
|
|
66
|
+
approvalRequired: never[];
|
|
67
|
+
};
|
|
68
|
+
canReply: {
|
|
69
|
+
always: string[];
|
|
70
|
+
approvalRequired: never[];
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
published: string;
|
|
74
|
+
replies: {
|
|
75
|
+
first: {
|
|
76
|
+
id: string;
|
|
77
|
+
next: string;
|
|
78
|
+
partOf: string;
|
|
79
|
+
type: string;
|
|
80
|
+
};
|
|
81
|
+
id: string;
|
|
82
|
+
type: string;
|
|
83
|
+
};
|
|
84
|
+
sensitive: boolean;
|
|
85
|
+
summary: string;
|
|
86
|
+
tag: {
|
|
87
|
+
href: string;
|
|
88
|
+
name: string;
|
|
89
|
+
type: string;
|
|
90
|
+
};
|
|
91
|
+
to: string;
|
|
92
|
+
type: string;
|
|
93
|
+
url: string;
|
|
94
|
+
};
|
|
95
|
+
published: string;
|
|
96
|
+
to: string;
|
|
97
|
+
type: string;
|
|
98
|
+
};
|
|
99
|
+
/** A follow of the local actor, written with a string `@context`. */
|
|
100
|
+
export declare const GOTOSOCIAL_FOLLOW: {
|
|
101
|
+
"@context": string;
|
|
102
|
+
actor: string;
|
|
103
|
+
id: string;
|
|
104
|
+
object: string;
|
|
105
|
+
type: string;
|
|
106
|
+
};
|
|
107
|
+
/** Pinned posts: an ordered collection whose items are IRIs. */
|
|
108
|
+
export declare const GOTOSOCIAL_FEATURED: {
|
|
109
|
+
"@context": string;
|
|
110
|
+
id: string;
|
|
111
|
+
orderedItems: string[];
|
|
112
|
+
totalItems: number;
|
|
113
|
+
type: string;
|
|
114
|
+
};
|