@takosjp/yurucommu-core 3.4.0 → 3.4.3
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/migrations/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.ts +1 -0
- package/src/backend/index.ts +62 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +13 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +38 -1
- package/src/backend/retention.ts +78 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/actors.ts +5 -5
- package/src/backend/routes/auth.ts +2 -1
- package/src/backend/routes/posts/post-helpers.ts +42 -23
- package/src/backend/routes/stories/routes.ts +5 -7
- package/src/backend/runtime/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +9 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { Database } from "../../../../db/index.ts";
|
|
2
|
-
import { and, eq, gt,
|
|
3
|
-
import {
|
|
4
|
-
activities,
|
|
5
|
-
actors,
|
|
6
|
-
follows,
|
|
7
|
-
likes,
|
|
8
|
-
objects,
|
|
9
|
-
} from "../../../../db/index.ts";
|
|
2
|
+
import { and, eq, gt, or, sql } from "drizzle-orm";
|
|
3
|
+
import { activities, actors, follows } from "../../../../db/index.ts";
|
|
10
4
|
import {
|
|
11
5
|
activityApId,
|
|
12
6
|
generateId,
|
|
@@ -43,6 +37,7 @@ export async function handleFollow(
|
|
|
43
37
|
recipient: ActorRow,
|
|
44
38
|
actor: string,
|
|
45
39
|
baseUrl: string,
|
|
40
|
+
sourceActivityId: string = activity.id || "",
|
|
46
41
|
) {
|
|
47
42
|
const db = c.get("db");
|
|
48
43
|
|
|
@@ -152,7 +147,9 @@ export async function handleFollow(
|
|
|
152
147
|
id: acceptId,
|
|
153
148
|
type: "Accept",
|
|
154
149
|
actor: recipient.apId,
|
|
155
|
-
|
|
150
|
+
// Echo the peer's bounded protocol id, not our origin-bound internal
|
|
151
|
+
// ledger id. Remote servers match Accept.object to their Follow id.
|
|
152
|
+
object: sourceActivityId || activityId,
|
|
156
153
|
};
|
|
157
154
|
|
|
158
155
|
// Store accept activity before enqueue.
|
|
@@ -160,7 +157,7 @@ export async function handleFollow(
|
|
|
160
157
|
apId: acceptId,
|
|
161
158
|
type: "Accept",
|
|
162
159
|
actorApId: recipient.apId,
|
|
163
|
-
objectApId: activityId,
|
|
160
|
+
objectApId: sourceActivityId || activityId,
|
|
164
161
|
rawJson: JSON.stringify(acceptActivity),
|
|
165
162
|
direction: "outbound",
|
|
166
163
|
});
|
|
@@ -294,10 +291,20 @@ export async function handleReject(
|
|
|
294
291
|
// Undo handler
|
|
295
292
|
// ---------------------------------------------------------------------------
|
|
296
293
|
|
|
294
|
+
/**
|
|
295
|
+
* `recipient` is null when the Undo arrived at the shared inbox as an
|
|
296
|
+
* INSTANCE-scoped activity — i.e. Undo(Like|Announce), which is actor-keyed
|
|
297
|
+
* and idempotent and names no local actor. Undo(Follow|Block) is object-actor
|
|
298
|
+
* scoped and always dispatched with its resolved target, so a null recipient
|
|
299
|
+
* on a follow branch means the target could not be resolved; that is logged
|
|
300
|
+
* and refused rather than guessed at. The previous code guessed: it fell back
|
|
301
|
+
* to deleting the signer's likes across EVERY object the "recipient" had
|
|
302
|
+
* authored and rewriting every one of that actor's `objects` rows.
|
|
303
|
+
*/
|
|
297
304
|
export async function handleUndo(
|
|
298
305
|
c: ActivityContext,
|
|
299
306
|
activity: Activity,
|
|
300
|
-
recipient: ActorRow,
|
|
307
|
+
recipient: ActorRow | null,
|
|
301
308
|
actor: string,
|
|
302
309
|
_baseUrl: string,
|
|
303
310
|
) {
|
|
@@ -318,9 +325,17 @@ export async function handleUndo(
|
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
if (typeIncludes(objectType, "Follow")) {
|
|
328
|
+
if (!recipient) {
|
|
329
|
+
log.warn("Undo(Follow) without a resolved local target", {
|
|
330
|
+
event: "ap.undo.follow.no_recipient",
|
|
331
|
+
actor,
|
|
332
|
+
activityId: activity.id,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
321
336
|
await undoFollow(db, objectId, actor, recipient);
|
|
322
337
|
} else if (typeIncludes(objectType, "Like")) {
|
|
323
|
-
await undoLike(db, objectId, activityObject, actor
|
|
338
|
+
await undoLike(db, objectId, activityObject, actor);
|
|
324
339
|
} else if (typeIncludes(objectType, "Announce")) {
|
|
325
340
|
await undoAnnounce(db, objectId, activityObject, actor);
|
|
326
341
|
}
|
|
@@ -339,16 +354,25 @@ async function resolveUndoByActivityId(
|
|
|
339
354
|
db: Database,
|
|
340
355
|
objectId: string,
|
|
341
356
|
actor: string,
|
|
342
|
-
recipient: ActorRow,
|
|
357
|
+
recipient: ActorRow | null,
|
|
343
358
|
): Promise<boolean> {
|
|
344
359
|
const originalActivity = await db
|
|
345
360
|
.select({
|
|
361
|
+
apId: activities.apId,
|
|
346
362
|
type: activities.type,
|
|
347
363
|
objectApId: activities.objectApId,
|
|
348
364
|
actorApId: activities.actorApId,
|
|
349
365
|
})
|
|
350
366
|
.from(activities)
|
|
351
|
-
.where(
|
|
367
|
+
.where(
|
|
368
|
+
or(
|
|
369
|
+
eq(activities.apId, objectId),
|
|
370
|
+
and(
|
|
371
|
+
eq(activities.direction, "inbound"),
|
|
372
|
+
sql`json_extract(${activities.rawJson}, '$.id') = ${objectId}`,
|
|
373
|
+
),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
352
376
|
.get();
|
|
353
377
|
if (!originalActivity) return false;
|
|
354
378
|
|
|
@@ -363,7 +387,17 @@ async function resolveUndoByActivityId(
|
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
if (originalActivity.type === "Follow") {
|
|
366
|
-
|
|
390
|
+
// The followerCount decrement is keyed on the followed actor, so an
|
|
391
|
+
// instance-dispatched Undo (no resolved local target) must not run it.
|
|
392
|
+
if (!recipient) {
|
|
393
|
+
log.warn("Undo(Follow) by activity id without a resolved local target", {
|
|
394
|
+
event: "ap.undo.follow.no_recipient",
|
|
395
|
+
actor,
|
|
396
|
+
activityId: objectId,
|
|
397
|
+
});
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
const follow = await findFollowByActivityId(db, originalActivity.apId);
|
|
367
401
|
if (follow) {
|
|
368
402
|
await undoFollowEdge(
|
|
369
403
|
db,
|
|
@@ -391,7 +425,14 @@ async function resolveUndoByActivityId(
|
|
|
391
425
|
// A crash-then-retry converges (the recompute is idempotent against the true
|
|
392
426
|
// edge set) instead of permanently over-counting on the retry's no-op
|
|
393
427
|
// delete. The actor-mismatch guard above still constrains who may undo.
|
|
394
|
-
await undoInteraction(
|
|
428
|
+
await undoInteraction(
|
|
429
|
+
db,
|
|
430
|
+
kind,
|
|
431
|
+
countField,
|
|
432
|
+
undefined,
|
|
433
|
+
originalActivity.apId,
|
|
434
|
+
actor,
|
|
435
|
+
);
|
|
395
436
|
return true;
|
|
396
437
|
}
|
|
397
438
|
|
|
@@ -476,12 +517,26 @@ async function undoFollow(
|
|
|
476
517
|
await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
|
|
477
518
|
}
|
|
478
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Undo(Like).
|
|
522
|
+
*
|
|
523
|
+
* If the like edge cannot be identified — from the inner object's `object`, or
|
|
524
|
+
* from the referenced Like activity id — there is NOTHING to undo and we say
|
|
525
|
+
* so. There used to be a "last resort" here that deleted every like the signer
|
|
526
|
+
* held on ANY object authored by the delivery recipient and then recomputed
|
|
527
|
+
* `likeCount` for EVERY one of that actor's `objects` rows. It was reachable
|
|
528
|
+
* from a malformed `{type:'Undo', object:{type:'Like', id:...}}` with no inner
|
|
529
|
+
* `object` (and from the actor-mismatch branch), and under the shared inbox it
|
|
530
|
+
* ran once per local follower, so a single non-conformant peer could both wipe
|
|
531
|
+
* a user's likes across an author's whole timeline and amplify a full-table
|
|
532
|
+
* UPDATE by the follower count. Nothing about the activity authorised that
|
|
533
|
+
* scope: the only thing it identified was the signer.
|
|
534
|
+
*/
|
|
479
535
|
async function undoLike(
|
|
480
536
|
db: Database,
|
|
481
537
|
objectId: string | null,
|
|
482
538
|
activityObject: ReturnType<typeof getActivityObject>,
|
|
483
539
|
actor: string,
|
|
484
|
-
recipient: ActorRow,
|
|
485
540
|
): Promise<void> {
|
|
486
541
|
const handled = await undoInteraction(
|
|
487
542
|
db,
|
|
@@ -493,43 +548,13 @@ async function undoLike(
|
|
|
493
548
|
);
|
|
494
549
|
if (handled) return;
|
|
495
550
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
// Deriving each affected object's likeCount from COUNT(*) of the remaining like
|
|
503
|
-
// rows makes the fallback idempotent so a crash-then-retry CONVERGES instead of
|
|
504
|
-
// drifting.
|
|
505
|
-
// The recipient (local user) can have authored thousands of objects; splicing
|
|
506
|
-
// every id into an `IN (...)` would blow D1's 100-bound-parameter ceiling.
|
|
507
|
-
// Express the affected set as a SUBQUERY (delete) and a direct predicate
|
|
508
|
-
// (update) so no per-object parameter is bound. An empty set is a harmless
|
|
509
|
-
// no-op batch, so the previous early-return is unnecessary.
|
|
510
|
-
const recipientObjectsSubquery = db
|
|
511
|
-
.select({ apId: objects.apId })
|
|
512
|
-
.from(objects)
|
|
513
|
-
.where(eq(objects.attributedTo, recipient.apId));
|
|
514
|
-
|
|
515
|
-
await runBatch(db, [
|
|
516
|
-
db
|
|
517
|
-
.delete(likes)
|
|
518
|
-
.where(
|
|
519
|
-
and(
|
|
520
|
-
eq(likes.actorApId, actor),
|
|
521
|
-
inArray(likes.objectApId, recipientObjectsSubquery),
|
|
522
|
-
),
|
|
523
|
-
),
|
|
524
|
-
db
|
|
525
|
-
.update(objects)
|
|
526
|
-
.set({
|
|
527
|
-
likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId})`,
|
|
528
|
-
})
|
|
529
|
-
.where(eq(objects.attributedTo, recipient.apId)),
|
|
530
|
-
]);
|
|
551
|
+
log.warn("Undo(Like) names no resolvable like edge; ignoring", {
|
|
552
|
+
event: "ap.undo.like.unresolved",
|
|
553
|
+
actor,
|
|
554
|
+
activityId: objectId,
|
|
555
|
+
object: activityObject?.object,
|
|
556
|
+
});
|
|
531
557
|
}
|
|
532
|
-
|
|
533
558
|
async function undoAnnounce(
|
|
534
559
|
db: Database,
|
|
535
560
|
objectId: string | null,
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
getActivityObjectId,
|
|
21
21
|
} from "../inbox-types.ts";
|
|
22
22
|
import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
|
|
23
|
+
import { fetchAndPersistAnnouncedNote } from "./inbox-content-handlers.ts";
|
|
23
24
|
import { isLocal } from "../../../lib/ap-ids.ts";
|
|
25
|
+
import { notDeleted } from "../../../../db/index.ts";
|
|
24
26
|
import {
|
|
25
27
|
actorIsBlockedBy,
|
|
26
28
|
canViewerReadObjectFull,
|
|
@@ -174,10 +176,15 @@ async function handleInteraction(
|
|
|
174
176
|
// Like handler
|
|
175
177
|
// ---------------------------------------------------------------------------
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A Like is INSTANCE-scoped: the affected object (and the local owner to
|
|
181
|
+
* notify) is resolved from `activity.object`, never from a delivery recipient.
|
|
182
|
+
* The recipient argument was already ignored; removing it is what stops the
|
|
183
|
+
* shared inbox from being able to run this once per local follower.
|
|
184
|
+
*/
|
|
177
185
|
export async function handleLike(
|
|
178
186
|
c: ActivityContext,
|
|
179
187
|
activity: Activity,
|
|
180
|
-
_recipient: ActorRow,
|
|
181
188
|
actor: string,
|
|
182
189
|
baseUrl: string,
|
|
183
190
|
) {
|
|
@@ -188,13 +195,70 @@ export async function handleLike(
|
|
|
188
195
|
// Announce handler (repost/boost)
|
|
189
196
|
// ---------------------------------------------------------------------------
|
|
190
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Does at least one (non-tombstoned) LOCAL actor have an accepted follow of
|
|
200
|
+
* `actorApId`? The fetch-and-store path below is gated on this so an arbitrary
|
|
201
|
+
* remote cannot use our inbox as an open relay: only a boost from someone a
|
|
202
|
+
* local user chose to follow may trigger an outbound object fetch.
|
|
203
|
+
*/
|
|
204
|
+
async function actorHasLocalFollowers(
|
|
205
|
+
db: Database,
|
|
206
|
+
actorApId: string,
|
|
207
|
+
): Promise<boolean> {
|
|
208
|
+
const row = await db
|
|
209
|
+
.select({ followerApId: follows.followerApId })
|
|
210
|
+
.from(follows)
|
|
211
|
+
.innerJoin(
|
|
212
|
+
actors,
|
|
213
|
+
and(eq(actors.apId, follows.followerApId), notDeleted(actors)),
|
|
214
|
+
)
|
|
215
|
+
.where(
|
|
216
|
+
and(eq(follows.followingApId, actorApId), eq(follows.status, "accepted")),
|
|
217
|
+
)
|
|
218
|
+
.limit(1)
|
|
219
|
+
.get();
|
|
220
|
+
return Boolean(row);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Instance-scoped, exactly like {@link handleLike}. */
|
|
191
224
|
export async function handleAnnounce(
|
|
192
225
|
c: ActivityContext,
|
|
193
226
|
activity: Activity,
|
|
194
|
-
_recipient: ActorRow,
|
|
195
227
|
actor: string,
|
|
196
228
|
baseUrl: string,
|
|
197
229
|
) {
|
|
230
|
+
// Fetch-and-store an UNKNOWN boosted remote object before recording the
|
|
231
|
+
// announce, so a followed remote's boost of a post this instance never saw
|
|
232
|
+
// still surfaces in feeds (previously the Announce only left a dangling
|
|
233
|
+
// announce edge + a counter no-op). Strictly gated: the target must be
|
|
234
|
+
// remote and unknown, and the booster must have at least one local follower
|
|
235
|
+
// (no open-relay amplification). On any fetch/validation failure the
|
|
236
|
+
// Announce degrades to exactly the old behavior — handleInteraction records
|
|
237
|
+
// the edge idempotently and the counter recompute no-ops on the absent row,
|
|
238
|
+
// so a later Create of the same object retroactively completes the boost.
|
|
239
|
+
const db = c.get("db");
|
|
240
|
+
const objectId = getActivityObjectId(activity);
|
|
241
|
+
if (objectId && !isLocal(objectId, baseUrl)) {
|
|
242
|
+
const known = await db
|
|
243
|
+
.select({ apId: objects.apId })
|
|
244
|
+
.from(objects)
|
|
245
|
+
.where(eq(objects.apId, objectId))
|
|
246
|
+
.get();
|
|
247
|
+
if (!known && (await actorHasLocalFollowers(db, actor))) {
|
|
248
|
+
const persisted = await fetchAndPersistAnnouncedNote(
|
|
249
|
+
db,
|
|
250
|
+
objectId,
|
|
251
|
+
baseUrl,
|
|
252
|
+
);
|
|
253
|
+
if (!persisted) {
|
|
254
|
+
log.debug("Announce target fetch skipped or failed", {
|
|
255
|
+
event: "ap.announce.object_fetch_failed",
|
|
256
|
+
actor,
|
|
257
|
+
objectId,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
198
262
|
await handleInteraction("announce", c, activity, actor, baseUrl);
|
|
199
263
|
}
|
|
200
264
|
|
|
@@ -23,18 +23,13 @@ import type { Activity } from "../inbox-types.ts";
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
type BatchStatement = BatchItem<"sqlite">;
|
|
26
|
-
interface BatchableDb {
|
|
27
|
-
batch(
|
|
28
|
-
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
29
|
-
): Promise<unknown>;
|
|
30
|
-
}
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
27
|
+
// The batch primitive lives with the other D1 write primitives (src/db/
|
|
28
|
+
// d1-write.ts) so the atomicity rule and the parameter-budget rule cannot
|
|
29
|
+
// drift apart. Re-exported here because the inbox handlers are its heaviest
|
|
30
|
+
// caller and this is the import path they already use.
|
|
31
|
+
export { runBatch } from "../../../../db/d1-write.ts";
|
|
32
|
+
import { runBatch } from "../../../../db/d1-write.ts";
|
|
38
33
|
|
|
39
34
|
// ---------------------------------------------------------------------------
|
|
40
35
|
// Shared helpers used by multiple inbox handler files
|
|
@@ -97,7 +92,7 @@ export async function findFollowByActivityId(
|
|
|
97
92
|
followingApId: string;
|
|
98
93
|
status: string;
|
|
99
94
|
} | null> {
|
|
100
|
-
|
|
95
|
+
let row = await db
|
|
101
96
|
.select({
|
|
102
97
|
followerApId: follows.followerApId,
|
|
103
98
|
followingApId: follows.followingApId,
|
|
@@ -106,6 +101,34 @@ export async function findFollowByActivityId(
|
|
|
106
101
|
.from(follows)
|
|
107
102
|
.where(eq(follows.activityApId, activityApIdValue))
|
|
108
103
|
.get();
|
|
104
|
+
if (!row) {
|
|
105
|
+
// Inbound envelopes use an origin-bound internal activities.apId. Undo
|
|
106
|
+
// carries the peer's protocol id, so resolve that bounded raw-json id back
|
|
107
|
+
// to the internal row before looking up the follow edge. The direct lookup
|
|
108
|
+
// above preserves compatibility with legacy rows.
|
|
109
|
+
const source = await db
|
|
110
|
+
.select({ apId: activities.apId })
|
|
111
|
+
.from(activities)
|
|
112
|
+
.where(
|
|
113
|
+
and(
|
|
114
|
+
eq(activities.direction, "inbound"),
|
|
115
|
+
sql`json_extract(${activities.rawJson}, '$.id') = ${activityApIdValue}`,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
.limit(2);
|
|
119
|
+
for (const candidate of source) {
|
|
120
|
+
row = await db
|
|
121
|
+
.select({
|
|
122
|
+
followerApId: follows.followerApId,
|
|
123
|
+
followingApId: follows.followingApId,
|
|
124
|
+
status: follows.status,
|
|
125
|
+
})
|
|
126
|
+
.from(follows)
|
|
127
|
+
.where(eq(follows.activityApId, candidate.apId))
|
|
128
|
+
.get();
|
|
129
|
+
if (row) break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
109
132
|
return row ?? null;
|
|
110
133
|
}
|
|
111
134
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-inbox addressing: which LOCAL actors is an inbound activity for?
|
|
3
|
+
*
|
|
4
|
+
* The old model was an exception list. Six types were "recipient independent"
|
|
5
|
+
* and dispatched once; everything else fell through to "fan out to every local
|
|
6
|
+
* actor that follows the SENDER". That fallback is a guess, and it is wrong in
|
|
7
|
+
* both directions:
|
|
8
|
+
*
|
|
9
|
+
* - A DM or a Like from someone the recipient does not follow resolved to
|
|
10
|
+
* ZERO recipients. The route then committed `processed = 1`, so the
|
|
11
|
+
* activity was permanently un-redeliverable — a silent drop with a 202.
|
|
12
|
+
* Our own delivery planner always prefers `endpoints.sharedInbox`, and so
|
|
13
|
+
* does Mastodon, so this is the NORMAL inbound path, not an edge case.
|
|
14
|
+
* - `Like` / `Announce` were fanned out once PER LOCAL FOLLOWER even though
|
|
15
|
+
* their handlers take `_recipient` and resolve the target from the object's
|
|
16
|
+
* `attributedTo`. Every extra follower was one more redundant whole-row
|
|
17
|
+
* counter recompute.
|
|
18
|
+
*
|
|
19
|
+
* The replacement is a total function over the handled activity types. Every
|
|
20
|
+
* type declares its addressing class, `satisfies` makes the map exhaustive, and
|
|
21
|
+
* a new handler therefore cannot be added without declaring where it is
|
|
22
|
+
* addressed — the enforcement is `bunx tsc --noEmit`, not a lint script.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
26
|
+
import type { Database } from "../../../db/index.ts";
|
|
27
|
+
import { actors, follows, inChunks } from "../../../db/index.ts";
|
|
28
|
+
import { isLocal } from "../../federation-helpers.ts";
|
|
29
|
+
import type { Activity } from "./inbox-types.ts";
|
|
30
|
+
import {
|
|
31
|
+
addressesPublic,
|
|
32
|
+
MAX_ADDRESS_ENTRIES,
|
|
33
|
+
} from "./handlers/inbox-content-handlers.ts";
|
|
34
|
+
|
|
35
|
+
export type ActorRow = typeof actors.$inferSelect;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How an inbound activity names its recipients.
|
|
39
|
+
*
|
|
40
|
+
* `audience` is never DECLARED — it is only ever the RESULT of resolving an
|
|
41
|
+
* `addressed` activity whose addressing actually contains a Public or
|
|
42
|
+
* followers-collection marker. That distinction is the whole point: the old
|
|
43
|
+
* code performed a follower fan-out because it had found no other recipient,
|
|
44
|
+
* rather than because the activity asked for one.
|
|
45
|
+
*/
|
|
46
|
+
export type AddressingClass =
|
|
47
|
+
"instance" | "object-actor" | "addressed" | "audience";
|
|
48
|
+
|
|
49
|
+
export type DeclaredAddressingClass = Exclude<AddressingClass, "audience">;
|
|
50
|
+
|
|
51
|
+
/** Activity types `dispatchUserActivity` handles. */
|
|
52
|
+
export const HANDLED_ACTIVITY_TYPES = [
|
|
53
|
+
"Follow",
|
|
54
|
+
"Accept",
|
|
55
|
+
"Undo",
|
|
56
|
+
"Like",
|
|
57
|
+
"Create",
|
|
58
|
+
"Delete",
|
|
59
|
+
"Announce",
|
|
60
|
+
"Update",
|
|
61
|
+
"Reject",
|
|
62
|
+
"Add",
|
|
63
|
+
"Remove",
|
|
64
|
+
"Block",
|
|
65
|
+
"Flag",
|
|
66
|
+
"Move",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
export type HandledActivityType = (typeof HANDLED_ACTIVITY_TYPES)[number];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The addressing declaration. `satisfies` keeps it total: adding a handler to
|
|
73
|
+
* {@link HANDLED_ACTIVITY_TYPES} without a class here is a type error.
|
|
74
|
+
*
|
|
75
|
+
* `Like` and `Announce` are `instance`: their handlers already ignore the
|
|
76
|
+
* recipient argument and resolve the affected object (and its owner) from
|
|
77
|
+
* `activity.object`, so dispatching them once is not a behaviour change — it
|
|
78
|
+
* only removes the per-follower duplication.
|
|
79
|
+
*
|
|
80
|
+
* `Undo` is declared `object-actor` because Undo(Follow|Block) must reach the
|
|
81
|
+
* followed/blocked actor. Undo(Like|Announce) carries no local actor target and
|
|
82
|
+
* is actor-keyed + idempotent, so the resolver downgrades it to `instance`.
|
|
83
|
+
*/
|
|
84
|
+
export const ACTIVITY_ADDRESSING = {
|
|
85
|
+
Accept: "instance",
|
|
86
|
+
Delete: "instance",
|
|
87
|
+
Update: "instance",
|
|
88
|
+
Reject: "instance",
|
|
89
|
+
Flag: "instance",
|
|
90
|
+
Move: "instance",
|
|
91
|
+
Like: "instance",
|
|
92
|
+
Announce: "instance",
|
|
93
|
+
|
|
94
|
+
Follow: "object-actor",
|
|
95
|
+
Block: "object-actor",
|
|
96
|
+
Undo: "object-actor",
|
|
97
|
+
|
|
98
|
+
Create: "addressed",
|
|
99
|
+
Add: "addressed",
|
|
100
|
+
Remove: "addressed",
|
|
101
|
+
} satisfies Record<HandledActivityType, DeclaredAddressingClass>;
|
|
102
|
+
|
|
103
|
+
export function isHandledActivityType(
|
|
104
|
+
type: string,
|
|
105
|
+
): type is HandledActivityType {
|
|
106
|
+
return type in ACTIVITY_ADDRESSING;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every IRI the activity uses to name an audience, from the ENVELOPE and from
|
|
111
|
+
* an embedded object. The envelope fields used to be discarded by
|
|
112
|
+
* `parseActivity`, which is why a DM's addressing never reached routing.
|
|
113
|
+
*
|
|
114
|
+
* `bto`/`bcc` are included: a peer that strips them before delivery simply
|
|
115
|
+
* contributes nothing, and one that does not must still be honoured.
|
|
116
|
+
*/
|
|
117
|
+
export function collectAddresses(activity: Activity): string[] {
|
|
118
|
+
const object = typeof activity.object === "string" ? null : activity.object;
|
|
119
|
+
const all = [
|
|
120
|
+
...(activity.to ?? []),
|
|
121
|
+
...(activity.cc ?? []),
|
|
122
|
+
...(activity.bto ?? []),
|
|
123
|
+
...(activity.bcc ?? []),
|
|
124
|
+
...(activity.audience ?? []),
|
|
125
|
+
...(object?.to ?? []),
|
|
126
|
+
...(object?.cc ?? []),
|
|
127
|
+
];
|
|
128
|
+
// Same bound the persisted addressing arrays use (policy defined once, in
|
|
129
|
+
// inbox-content-handlers): a remote must not be able to make one delivery
|
|
130
|
+
// cost an unbounded number of actor lookups.
|
|
131
|
+
return [...new Set(all)].slice(0, MAX_ADDRESS_ENTRIES);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A followers-collection IRI. Matched by suffix, exactly like the visibility
|
|
136
|
+
* classifier in inbox-content-handlers — resolving the sender's real
|
|
137
|
+
* `followers` URL would need a network fetch for a remote actor.
|
|
138
|
+
*/
|
|
139
|
+
function addressesFollowersCollection(addresses: readonly string[]): boolean {
|
|
140
|
+
return addresses.some((a) => a.endsWith("/followers"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type AddressingResolution = {
|
|
144
|
+
/** The class actually applied (may be `audience`, which is never declared). */
|
|
145
|
+
readonly cls: AddressingClass;
|
|
146
|
+
readonly recipients: readonly ActorRow[];
|
|
147
|
+
/** Addressing IRIs read off the activity, for logging. */
|
|
148
|
+
readonly addresses: readonly string[];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Local actors explicitly named in the addressing.
|
|
153
|
+
*/
|
|
154
|
+
async function resolveAddressedLocalActors(
|
|
155
|
+
db: Database,
|
|
156
|
+
addresses: readonly string[],
|
|
157
|
+
baseUrl: string,
|
|
158
|
+
): Promise<ActorRow[]> {
|
|
159
|
+
const localIris = addresses.filter(
|
|
160
|
+
(a) => isLocal(a, baseUrl) && !addressesPublic([a]),
|
|
161
|
+
);
|
|
162
|
+
if (localIris.length === 0) return [];
|
|
163
|
+
// Bounded by MAX_ADDRESS_ENTRIES, but routed through inChunks anyway so the
|
|
164
|
+
// D1 parameter rule holds if that bound is ever raised.
|
|
165
|
+
return await inChunks(localIris, (chunk) =>
|
|
166
|
+
db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Local actors with an accepted follow of `senderApId`.
|
|
172
|
+
*/
|
|
173
|
+
async function resolveLocalFollowers(
|
|
174
|
+
db: Database,
|
|
175
|
+
senderApId: string,
|
|
176
|
+
baseUrl: string,
|
|
177
|
+
limit: number,
|
|
178
|
+
): Promise<ActorRow[]> {
|
|
179
|
+
const rows = await db
|
|
180
|
+
.select({ followerApId: follows.followerApId })
|
|
181
|
+
.from(follows)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(follows.followingApId, senderApId),
|
|
185
|
+
eq(follows.status, "accepted"),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
.limit(limit);
|
|
189
|
+
|
|
190
|
+
const localFollowerApIds = rows
|
|
191
|
+
.map((row) => row.followerApId)
|
|
192
|
+
.filter((apId) => isLocal(apId, baseUrl));
|
|
193
|
+
if (localFollowerApIds.length === 0) return [];
|
|
194
|
+
|
|
195
|
+
return await inChunks(localFollowerApIds, (chunk) =>
|
|
196
|
+
db.query.actors.findMany({ where: inArray(actors.apId, [...chunk]) }),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve an `addressed` activity's local recipients.
|
|
202
|
+
*
|
|
203
|
+
* A follower fan-out happens ONLY when the activity actually addresses Public
|
|
204
|
+
* or a followers collection (`audience`). An activity that names specific
|
|
205
|
+
* actors resolves to exactly those (`addressed`). An activity that names
|
|
206
|
+
* neither resolves to nothing, and the caller must treat that as UNDELIVERABLE
|
|
207
|
+
* rather than as a completed no-op — the old code's `processed = 1` on that
|
|
208
|
+
* branch is what made non-follower DMs unrecoverable.
|
|
209
|
+
*/
|
|
210
|
+
export async function resolveAddressedRecipients(
|
|
211
|
+
db: Database,
|
|
212
|
+
activity: Activity,
|
|
213
|
+
senderApId: string,
|
|
214
|
+
baseUrl: string,
|
|
215
|
+
followerFanoutLimit: number,
|
|
216
|
+
): Promise<AddressingResolution> {
|
|
217
|
+
const addresses = collectAddresses(activity);
|
|
218
|
+
const addressed = await resolveAddressedLocalActors(db, addresses, baseUrl);
|
|
219
|
+
|
|
220
|
+
const wantsAudience =
|
|
221
|
+
addressesPublic([...addresses]) || addressesFollowersCollection(addresses);
|
|
222
|
+
|
|
223
|
+
if (!wantsAudience) {
|
|
224
|
+
return { cls: "addressed", recipients: addressed, addresses };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const followers = await resolveLocalFollowers(
|
|
228
|
+
db,
|
|
229
|
+
senderApId,
|
|
230
|
+
baseUrl,
|
|
231
|
+
followerFanoutLimit,
|
|
232
|
+
);
|
|
233
|
+
const byApId = new Map<string, ActorRow>();
|
|
234
|
+
for (const row of [...addressed, ...followers]) byApId.set(row.apId, row);
|
|
235
|
+
return { cls: "audience", recipients: [...byApId.values()], addresses };
|
|
236
|
+
}
|
|
@@ -29,6 +29,14 @@ export type Activity = {
|
|
|
29
29
|
object?: string | ActivityObject;
|
|
30
30
|
target?: string | ActivityObject;
|
|
31
31
|
room?: string;
|
|
32
|
+
// Envelope addressing, preserved by parseActivity. Shared-inbox routing is
|
|
33
|
+
// derived from these (inbox-addressing.ts); before they were parsed the
|
|
34
|
+
// route had to guess the recipients from the sender's follower graph.
|
|
35
|
+
to?: string[];
|
|
36
|
+
cc?: string[];
|
|
37
|
+
bto?: string[];
|
|
38
|
+
bcc?: string[];
|
|
39
|
+
audience?: string[];
|
|
32
40
|
};
|
|
33
41
|
|
|
34
42
|
export type RemoteActor = {
|