@rmdes/indiekit-endpoint-activitypub 2.13.0 → 2.15.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.
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Inbox listener registrations for the Fedify Federation instance.
3
3
  *
4
- * Each listener handles a specific ActivityPub activity type received
5
- * in the actor's inbox (Follow, Undo, Like, Announce, Create, Delete, Move).
4
+ * Each listener is a thin shim that:
5
+ * 1. Checks server-level blocks (Redis, O(1))
6
+ * 2. Updates key freshness tracking
7
+ * 3. Performs synchronous-only work (Follow Accept, Block follower removal)
8
+ * 4. Enqueues the activity for async processing
6
9
  */
7
10
 
8
11
  import {
9
12
  Accept,
10
13
  Add,
11
14
  Announce,
12
- Article,
13
15
  Block,
14
16
  Create,
15
17
  Delete,
@@ -17,20 +19,18 @@ import {
17
19
  Follow,
18
20
  Like,
19
21
  Move,
20
- Note,
21
22
  Reject,
22
23
  Remove,
23
24
  Undo,
24
25
  Update,
25
26
  } from "@fedify/fedify/vocab";
26
27
 
27
- import { logActivity as logActivityShared } from "./activity-log.js";
28
- import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
29
- import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
28
+ import { isServerBlocked } from "./storage/server-blocks.js";
29
+ import { touchKeyFreshness } from "./key-refresh.js";
30
+ import { resetDeliveryStrikes } from "./outbox-failure.js";
31
+ import { enqueueActivity } from "./inbox-queue.js";
32
+ import { extractActorInfo } from "./timeline-store.js";
30
33
  import { addNotification } from "./storage/notifications.js";
31
- import { addMessage } from "./storage/messages.js";
32
- import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
33
- import { getFollowedTags } from "./storage/followed-tags.js";
34
34
 
35
35
  /**
36
36
  * Register all inbox listeners on a federation's inbox chain.
@@ -41,54 +41,21 @@ import { getFollowedTags } from "./storage/followed-tags.js";
41
41
  * @param {string} options.handle - Actor handle
42
42
  * @param {boolean} options.storeRawActivities - Whether to store raw JSON
43
43
  */
44
- /** @type {string} ActivityStreams Public Collection constant */
45
- const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
46
-
47
- /**
48
- * Determine if an object is a direct message (DM).
49
- * A DM is addressed only to specific actors — no PUBLIC_COLLECTION,
50
- * no followers collection, and includes our actor URL.
51
- *
52
- * @param {object} object - Fedify object (Note, Article, etc.)
53
- * @param {string} ourActorUrl - Our actor's URL
54
- * @param {string} followersUrl - Our followers collection URL
55
- * @returns {boolean}
56
- */
57
- function isDirectMessage(object, ourActorUrl, followersUrl) {
58
- const allAddressed = [
59
- ...object.toIds.map((u) => u.href),
60
- ...object.ccIds.map((u) => u.href),
61
- ...object.btoIds.map((u) => u.href),
62
- ...object.bccIds.map((u) => u.href),
63
- ];
64
-
65
- // Must be addressed to us
66
- if (!allAddressed.includes(ourActorUrl)) return false;
67
-
68
- // Must NOT include public collection
69
- if (allAddressed.some((u) => u === PUBLIC || u === "as:Public")) return false;
70
-
71
- // Must NOT include our followers collection
72
- if (followersUrl && allAddressed.includes(followersUrl)) return false;
73
-
74
- return true;
75
- }
76
-
77
44
  export function registerInboxListeners(inboxChain, options) {
78
- const { collections, handle, storeRawActivities } = options;
79
-
80
- /**
81
- * Get an authenticated DocumentLoader that signs outbound fetches with
82
- * our actor's key. This allows .getActor()/.getObject() to succeed
83
- * against Authorized Fetch (Secure Mode) servers like hachyderm.io.
84
- *
85
- * @param {import("@fedify/fedify").Context} ctx - Fedify context
86
- * @returns {Promise<import("@fedify/fedify").DocumentLoader>}
87
- */
45
+ const { collections, handle } = options;
46
+
88
47
  const getAuthLoader = (ctx) => ctx.getDocumentLoader({ identifier: handle });
89
48
 
90
49
  inboxChain
50
+ // ── Follow ──────────────────────────────────────────────────────
51
+ // Synchronous: Accept/Reject + follower storage (federation requirement)
52
+ // Async: notification + activity log
91
53
  .on(Follow, async (ctx, follow) => {
54
+ const actorUrl = follow.actorId?.href || "";
55
+ if (await isServerBlocked(actorUrl, collections)) return;
56
+ await touchKeyFreshness(collections, actorUrl);
57
+ await resetDeliveryStrikes(collections, actorUrl);
58
+
92
59
  const authLoader = await getAuthLoader(ctx);
93
60
  const followerActor = await follow.getActor({ documentLoader: authLoader });
94
61
  if (!followerActor?.id) return;
@@ -99,7 +66,6 @@ export function registerInboxListeners(inboxChain, options) {
99
66
  followerActor.preferredUsername?.toString() ||
100
67
  followerUrl;
101
68
 
102
- // Build common follower data
103
69
  const followerData = {
104
70
  actorUrl: followerUrl,
105
71
  handle: followerActor.preferredUsername?.toString() || "",
@@ -111,12 +77,10 @@ export function registerInboxListeners(inboxChain, options) {
111
77
  sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
112
78
  };
113
79
 
114
- // Check if manual approval is enabled
115
80
  const profile = await collections.ap_profile.findOne({});
116
81
  const manualApproval = profile?.manuallyApprovesFollowers || false;
117
82
 
118
83
  if (manualApproval && collections.ap_pending_follows) {
119
- // Store as pending — do NOT send Accept yet
120
84
  await collections.ap_pending_follows.updateOne(
121
85
  { actorUrl: followerUrl },
122
86
  {
@@ -129,15 +93,7 @@ export function registerInboxListeners(inboxChain, options) {
129
93
  { upsert: true },
130
94
  );
131
95
 
132
- await logActivity(collections, storeRawActivities, {
133
- direction: "inbound",
134
- type: "Follow",
135
- actorUrl: followerUrl,
136
- actorName: followerName,
137
- summary: `${followerName} requested to follow you`,
138
- });
139
-
140
- // Notification with type "follow_request"
96
+ // Notification for follow request (synchronous needed for UI)
141
97
  const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
142
98
  await addNotification(collections, {
143
99
  uid: follow.id?.href || `follow_request:${followerUrl}`,
@@ -150,7 +106,6 @@ export function registerInboxListeners(inboxChain, options) {
150
106
  createdAt: new Date().toISOString(),
151
107
  });
152
108
  } else {
153
- // Auto-accept: store follower + send Accept back
154
109
  await collections.ap_followers.updateOne(
155
110
  { actorUrl: followerUrl },
156
111
  {
@@ -172,15 +127,7 @@ export function registerInboxListeners(inboxChain, options) {
172
127
  { orderingKey: followerUrl },
173
128
  );
174
129
 
175
- await logActivity(collections, storeRawActivities, {
176
- direction: "inbound",
177
- type: "Follow",
178
- actorUrl: followerUrl,
179
- actorName: followerName,
180
- summary: `${followerName} followed you`,
181
- });
182
-
183
- // Store notification
130
+ // Notification for follow (synchronous needed for UI)
184
131
  const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
185
132
  await addNotification(collections, {
186
133
  uid: follow.id?.href || `follow:${followerUrl}`,
@@ -193,680 +140,217 @@ export function registerInboxListeners(inboxChain, options) {
193
140
  createdAt: new Date().toISOString(),
194
141
  });
195
142
  }
143
+
144
+ // Enqueue async portion (activity log)
145
+ await enqueueActivity(collections, {
146
+ activityType: "Follow",
147
+ actorUrl,
148
+ rawJson: await follow.toJsonLd(),
149
+ });
196
150
  })
151
+
152
+ // ── Undo ────────────────────────────────────────────────────────
197
153
  .on(Undo, async (ctx, undo) => {
198
154
  const actorUrl = undo.actorId?.href || "";
199
- const authLoader = await getAuthLoader(ctx);
200
- let inner;
201
- try {
202
- inner = await undo.getObject({ documentLoader: authLoader });
203
- } catch {
204
- // Inner activity not dereferenceable — can't determine what was undone
205
- return;
206
- }
155
+ if (await isServerBlocked(actorUrl, collections)) return;
156
+ await touchKeyFreshness(collections, actorUrl);
157
+ await resetDeliveryStrikes(collections, actorUrl);
207
158
 
208
- if (inner instanceof Follow) {
209
- await collections.ap_followers.deleteOne({ actorUrl });
210
- await logActivity(collections, storeRawActivities, {
211
- direction: "inbound",
212
- type: "Undo(Follow)",
213
- actorUrl,
214
- summary: `${actorUrl} unfollowed you`,
215
- });
216
- } else if (inner instanceof Like) {
217
- const objectId = inner.objectId?.href || "";
218
- await collections.ap_activities.deleteOne({
219
- type: "Like",
220
- actorUrl,
221
- objectUrl: objectId,
222
- });
223
- } else if (inner instanceof Announce) {
224
- const objectId = inner.objectId?.href || "";
225
- await collections.ap_activities.deleteOne({
226
- type: "Announce",
227
- actorUrl,
228
- objectUrl: objectId,
229
- });
230
- } else {
231
- const typeName = inner?.constructor?.name || "unknown";
232
- await logActivity(collections, storeRawActivities, {
233
- direction: "inbound",
234
- type: `Undo(${typeName})`,
235
- actorUrl,
236
- summary: `${actorUrl} undid ${typeName}`,
237
- });
238
- }
159
+ await enqueueActivity(collections, {
160
+ activityType: "Undo",
161
+ actorUrl,
162
+ rawJson: await undo.toJsonLd(),
163
+ });
239
164
  })
165
+
166
+ // ── Accept ──────────────────────────────────────────────────────
240
167
  .on(Accept, async (ctx, accept) => {
241
- // Handle Accept(Follow) remote server accepted our Follow request.
242
- // We don't inspect the inner object type because Fedify often resolves
243
- // it to a Person (the Follow's target) rather than the Follow itself.
244
- // Instead, we match directly against ap_following — if we have a
245
- // pending follow for this actor, any Accept from them confirms it.
246
- const authLoader = await getAuthLoader(ctx);
247
- const actorObj = await accept.getActor({ documentLoader: authLoader });
248
- const actorUrl = actorObj?.id?.href || "";
249
- if (!actorUrl) return;
250
-
251
- const result = await collections.ap_following.findOneAndUpdate(
252
- {
253
- actorUrl,
254
- source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
255
- },
256
- {
257
- $set: {
258
- source: "federation",
259
- acceptedAt: new Date().toISOString(),
260
- },
261
- $unset: {
262
- refollowAttempts: "",
263
- refollowLastAttempt: "",
264
- refollowError: "",
265
- },
266
- },
267
- { returnDocument: "after" },
268
- );
269
-
270
- if (result) {
271
- const actorName =
272
- result.name || result.handle || actorUrl;
273
- await logActivity(collections, storeRawActivities, {
274
- direction: "inbound",
275
- type: "Accept(Follow)",
276
- actorUrl,
277
- actorName,
278
- summary: `${actorName} accepted our Follow`,
279
- });
280
- }
168
+ const actorUrl = accept.actorId?.href || "";
169
+ if (await isServerBlocked(actorUrl, collections)) return;
170
+ await touchKeyFreshness(collections, actorUrl);
171
+ await resetDeliveryStrikes(collections, actorUrl);
172
+
173
+ await enqueueActivity(collections, {
174
+ activityType: "Accept",
175
+ actorUrl,
176
+ rawJson: await accept.toJsonLd(),
177
+ });
281
178
  })
179
+
180
+ // ── Reject ──────────────────────────────────────────────────────
282
181
  .on(Reject, async (ctx, reject) => {
283
- const authLoader = await getAuthLoader(ctx);
284
- const actorObj = await reject.getActor({ documentLoader: authLoader });
285
- const actorUrl = actorObj?.id?.href || "";
286
- if (!actorUrl) return;
287
-
288
- // Mark rejected follow in ap_following
289
- const result = await collections.ap_following.findOneAndUpdate(
290
- {
291
- actorUrl,
292
- source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
293
- },
294
- {
295
- $set: {
296
- source: "rejected",
297
- rejectedAt: new Date().toISOString(),
298
- },
299
- },
300
- { returnDocument: "after" },
301
- );
302
-
303
- if (result) {
304
- const actorName = result.name || result.handle || actorUrl;
305
- await logActivity(collections, storeRawActivities, {
306
- direction: "inbound",
307
- type: "Reject(Follow)",
308
- actorUrl,
309
- actorName,
310
- summary: `${actorName} rejected our Follow`,
311
- });
312
- }
313
- })
314
- .on(Like, async (ctx, like) => {
315
- // Use .objectId (non-fetching) for the liked URL — we only need the
316
- // URL to filter and log, not the full remote object.
317
- const objectId = like.objectId?.href || "";
182
+ const actorUrl = reject.actorId?.href || "";
183
+ if (await isServerBlocked(actorUrl, collections)) return;
184
+ await touchKeyFreshness(collections, actorUrl);
185
+ await resetDeliveryStrikes(collections, actorUrl);
318
186
 
319
- // Only log likes of our own content
320
- const pubUrl = collections._publicationUrl;
321
- if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
187
+ await enqueueActivity(collections, {
188
+ activityType: "Reject",
189
+ actorUrl,
190
+ rawJson: await reject.toJsonLd(),
191
+ });
192
+ })
322
193
 
323
- const authLoader = await getAuthLoader(ctx);
194
+ // ── Like ────────────────────────────────────────────────────────
195
+ .on(Like, async (ctx, like) => {
324
196
  const actorUrl = like.actorId?.href || "";
325
- let actorObj;
326
- try {
327
- actorObj = await like.getActor({ documentLoader: authLoader });
328
- } catch {
329
- actorObj = null;
330
- }
197
+ if (await isServerBlocked(actorUrl, collections)) return;
198
+ await touchKeyFreshness(collections, actorUrl);
199
+ await resetDeliveryStrikes(collections, actorUrl);
331
200
 
332
- const actorName =
333
- actorObj?.name?.toString() ||
334
- actorObj?.preferredUsername?.toString() ||
335
- actorUrl;
336
-
337
- // Extract actor info (including avatar) before logging so we can store it
338
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
339
-
340
- await logActivity(collections, storeRawActivities, {
341
- direction: "inbound",
342
- type: "Like",
201
+ await enqueueActivity(collections, {
202
+ activityType: "Like",
343
203
  actorUrl,
344
- actorName,
345
- actorAvatar: actorInfo.photo || "",
346
- objectUrl: objectId,
347
- summary: `${actorName} liked ${objectId}`,
348
- });
349
-
350
- // Store notification
351
- await addNotification(collections, {
352
- uid: like.id?.href || `like:${actorUrl}:${objectId}`,
353
- type: "like",
354
- actorUrl: actorInfo.url,
355
- actorName: actorInfo.name,
356
- actorPhoto: actorInfo.photo,
357
- actorHandle: actorInfo.handle,
358
- targetUrl: objectId,
359
- targetName: "", // Could fetch post title, but not critical
360
- published: like.published ? String(like.published) : new Date().toISOString(),
361
- createdAt: new Date().toISOString(),
204
+ objectUrl: like.objectId?.href || "",
205
+ rawJson: await like.toJsonLd(),
362
206
  });
363
207
  })
364
- .on(Announce, async (ctx, announce) => {
365
- const objectId = announce.objectId?.href || "";
366
- if (!objectId) return;
367
208
 
368
- const authLoader = await getAuthLoader(ctx);
209
+ // ── Announce ────────────────────────────────────────────────────
210
+ .on(Announce, async (ctx, announce) => {
369
211
  const actorUrl = announce.actorId?.href || "";
370
- const pubUrl = collections._publicationUrl;
371
-
372
- // Dual path logic: Notification vs Timeline
212
+ if (await isServerBlocked(actorUrl, collections)) return;
213
+ await touchKeyFreshness(collections, actorUrl);
214
+ await resetDeliveryStrikes(collections, actorUrl);
373
215
 
374
- // PATH 1: Boost of OUR content → Notification
375
- if (pubUrl && objectId.startsWith(pubUrl)) {
376
- let actorObj;
377
- try {
378
- actorObj = await announce.getActor({ documentLoader: authLoader });
379
- } catch {
380
- actorObj = null;
381
- }
382
-
383
- const actorName =
384
- actorObj?.name?.toString() ||
385
- actorObj?.preferredUsername?.toString() ||
386
- actorUrl;
387
-
388
- // Extract actor info (including avatar) before logging so we can store it
389
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
390
-
391
- // Log the boost activity
392
- await logActivity(collections, storeRawActivities, {
393
- direction: "inbound",
394
- type: "Announce",
395
- actorUrl,
396
- actorName,
397
- actorAvatar: actorInfo.photo || "",
398
- objectUrl: objectId,
399
- summary: `${actorName} boosted ${objectId}`,
400
- });
401
-
402
- // Create notification
403
- await addNotification(collections, {
404
- uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
405
- type: "boost",
406
- actorUrl: actorInfo.url,
407
- actorName: actorInfo.name,
408
- actorPhoto: actorInfo.photo,
409
- actorHandle: actorInfo.handle,
410
- targetUrl: objectId,
411
- targetName: "", // Could fetch post title, but not critical
412
- published: announce.published ? String(announce.published) : new Date().toISOString(),
413
- createdAt: new Date().toISOString(),
414
- });
415
-
416
- // Don't return — fall through to check if actor is also followed
417
- }
418
-
419
- // PATH 2: Boost from someone we follow → Timeline (store original post)
420
- const following = await collections.ap_following.findOne({ actorUrl });
421
- if (following) {
422
- try {
423
- // Fetch the original object being boosted (authenticated for Secure Mode servers)
424
- const object = await announce.getObject({ documentLoader: authLoader });
425
- if (!object) return;
426
-
427
- // Skip non-content objects (Lemmy/PieFed like/create activities
428
- // that resolve to activity IDs instead of actual Note/Article posts)
429
- const hasContent = object.content?.toString() || object.name?.toString();
430
- if (!hasContent) return;
431
-
432
- // Get booster actor info
433
- const boosterActor = await announce.getActor({ documentLoader: authLoader });
434
- const boosterInfo = await extractActorInfo(boosterActor, { documentLoader: authLoader });
435
-
436
- // Extract and store with boost metadata
437
- const timelineItem = await extractObjectData(object, {
438
- boostedBy: boosterInfo,
439
- boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
440
- documentLoader: authLoader,
441
- });
442
-
443
- await addTimelineItem(collections, timelineItem);
444
-
445
- // Fire-and-forget quote enrichment for boosted posts
446
- if (timelineItem.quoteUrl) {
447
- fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
448
- .catch((error) => {
449
- console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
450
- });
451
- }
452
- } catch (error) {
453
- // Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
454
- const cause = error?.cause?.code || error?.message || "unknown";
455
- console.warn(`[AP] Skipped boost from ${actorUrl}: ${cause}`);
456
- }
457
- }
216
+ await enqueueActivity(collections, {
217
+ activityType: "Announce",
218
+ actorUrl,
219
+ objectUrl: announce.objectId?.href || "",
220
+ rawJson: await announce.toJsonLd(),
221
+ });
458
222
  })
459
- .on(Create, async (ctx, create) => {
460
- const authLoader = await getAuthLoader(ctx);
461
- let object;
462
- try {
463
- object = await create.getObject({ documentLoader: authLoader });
464
- } catch {
465
- // Remote object not dereferenceable (deleted, etc.)
466
- return;
467
- }
468
- if (!object) return;
469
223
 
224
+ // ── Create ──────────────────────────────────────────────────────
225
+ .on(Create, async (ctx, create) => {
470
226
  const actorUrl = create.actorId?.href || "";
471
- let actorObj;
227
+ if (await isServerBlocked(actorUrl, collections)) return;
228
+ await touchKeyFreshness(collections, actorUrl);
229
+ await resetDeliveryStrikes(collections, actorUrl);
230
+
231
+ // Forward public replies to our posts to our followers.
232
+ // Must happen here (not in async handler) because forwardActivity
233
+ // is only available on InboxContext, not base Context.
234
+ const objectUrl = create.objectId?.href || "";
472
235
  try {
473
- actorObj = await create.getActor({ documentLoader: authLoader });
474
- } catch {
475
- // Actor not dereferenceable — use URL as fallback
476
- actorObj = null;
477
- }
478
- const actorName =
479
- actorObj?.name?.toString() ||
480
- actorObj?.preferredUsername?.toString() ||
481
- actorUrl;
482
-
483
- // --- DM detection ---
484
- // Check if this is a direct message before processing as reply/mention/timeline.
485
- // DMs are handled separately and stored in ap_messages instead of ap_timeline.
486
- const ourActorUrl = ctx.getActorUri(handle).href;
487
- const followersUrl = ctx.getFollowersUri(handle)?.href || "";
488
-
489
- if (isDirectMessage(object, ourActorUrl, followersUrl)) {
490
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
491
- const rawHtml = object.content?.toString() || "";
492
- const contentHtml = sanitizeContent(rawHtml);
493
- const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 500);
494
- const published = object.published ? String(object.published) : new Date().toISOString();
495
- const inReplyToDM = object.replyTargetId?.href || null;
496
-
497
- // Store as message
498
- await addMessage(collections, {
499
- uid: object.id?.href || `dm:${actorUrl}:${Date.now()}`,
500
- actorUrl: actorInfo.url,
501
- actorName: actorInfo.name,
502
- actorPhoto: actorInfo.photo,
503
- actorHandle: actorInfo.handle,
504
- content: {
505
- text: contentText,
506
- html: contentHtml,
507
- },
508
- inReplyTo: inReplyToDM,
509
- conversationId: actorInfo.url,
510
- direction: "inbound",
511
- published,
512
- createdAt: new Date().toISOString(),
513
- });
514
-
515
- // Also create a notification so DMs appear in the notification tab
516
- await addNotification(collections, {
517
- uid: `dm:${object.id?.href || `${actorUrl}:${Date.now()}`}`,
518
- url: object.url?.href || object.id?.href || "",
519
- type: "dm",
520
- actorUrl: actorInfo.url,
521
- actorName: actorInfo.name,
522
- actorPhoto: actorInfo.photo,
523
- actorHandle: actorInfo.handle,
524
- content: {
525
- text: contentText,
526
- html: contentHtml,
527
- },
528
- published,
529
- createdAt: new Date().toISOString(),
530
- });
531
-
532
- await logActivity(collections, storeRawActivities, {
533
- direction: "inbound",
534
- type: "DirectMessage",
535
- actorUrl,
536
- actorName,
537
- actorAvatar: actorInfo.photo || "",
538
- objectUrl: object.id?.href || "",
539
- content: contentText.substring(0, 100),
540
- summary: `${actorName} sent a direct message`,
541
- });
542
-
543
- return; // Don't process DMs as timeline/mention/reply
544
- }
545
-
546
- // Use replyTargetId (non-fetching) for the inReplyTo URL
547
- const inReplyTo = object.replyTargetId?.href || null;
548
-
549
- // Log replies to our posts (existing behavior for conversations)
550
- const pubUrl = collections._publicationUrl;
551
- if (inReplyTo) {
552
- const content = object.content?.toString() || "";
553
-
554
- // Extract actor info (including avatar) before logging so we can store it
555
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
556
-
557
- await logActivity(collections, storeRawActivities, {
558
- direction: "inbound",
559
- type: "Reply",
560
- actorUrl,
561
- actorName,
562
- actorAvatar: actorInfo.photo || "",
563
- objectUrl: object.id?.href || "",
564
- targetUrl: inReplyTo,
565
- content,
566
- summary: `${actorName} replied to ${inReplyTo}`,
567
- });
568
-
569
- // Create notification if reply is to one of OUR posts
570
- if (pubUrl && inReplyTo.startsWith(pubUrl)) {
571
- const rawHtml = object.content?.toString() || "";
572
- const contentHtml = sanitizeContent(rawHtml);
573
- const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
574
-
575
- await addNotification(collections, {
576
- uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
577
- url: object.url?.href || object.id?.href || "",
578
- type: "reply",
579
- actorUrl: actorInfo.url,
580
- actorName: actorInfo.name,
581
- actorPhoto: actorInfo.photo,
582
- actorHandle: actorInfo.handle,
583
- targetUrl: inReplyTo,
584
- targetName: "",
585
- content: {
586
- text: contentText,
587
- html: contentHtml,
588
- },
589
- published: object.published ? String(object.published) : new Date().toISOString(),
590
- createdAt: new Date().toISOString(),
591
- });
592
- }
593
- }
594
-
595
- // Check for mentions of our actor
596
- if (object.tag) {
597
- const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
598
- const ourActorUrl = ctx.getActorUri(handle).href;
599
-
600
- for (const tag of tags) {
601
- if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
602
- const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
603
- const rawMentionHtml = object.content?.toString() || "";
604
- const mentionHtml = sanitizeContent(rawMentionHtml);
605
- const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
606
-
607
- await addNotification(collections, {
608
- uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
609
- url: object.url?.href || object.id?.href || "",
610
- type: "mention",
611
- actorUrl: actorInfo.url,
612
- actorName: actorInfo.name,
613
- actorPhoto: actorInfo.photo,
614
- actorHandle: actorInfo.handle,
615
- content: {
616
- text: contentText,
617
- html: mentionHtml,
236
+ const obj = await create.getObject();
237
+ const inReplyTo = obj?.replyTargetId?.href || "";
238
+ if (
239
+ inReplyTo &&
240
+ collections._publicationUrl &&
241
+ inReplyTo.startsWith(collections._publicationUrl)
242
+ ) {
243
+ // Check if the reply is public (to/cc includes PUBLIC collection)
244
+ const toUrls = (obj.toIds || []).map((u) => u.href);
245
+ const ccUrls = (obj.ccIds || []).map((u) => u.href);
246
+ const isPublic = [...toUrls, ...ccUrls].includes(
247
+ "https://www.w3.org/ns/activitystreams#Public",
248
+ );
249
+ if (isPublic) {
250
+ await ctx.forwardActivity(
251
+ { identifier: handle },
252
+ "followers",
253
+ {
254
+ skipIfUnsigned: true,
255
+ preferSharedInbox: true,
256
+ excludeBaseUris: [new URL(ctx.origin)],
618
257
  },
619
- published: object.published ? String(object.published) : new Date().toISOString(),
620
- createdAt: new Date().toISOString(),
621
- });
622
-
623
- break; // Only create one mention notification per post
258
+ );
624
259
  }
625
260
  }
261
+ } catch (error) {
262
+ // Non-critical — forwarding failure shouldn't block processing
263
+ console.warn("[inbox-listeners] Reply forwarding failed:", error.message);
626
264
  }
627
265
 
628
- // Store timeline items from accounts we follow (native storage)
629
- const following = await collections.ap_following.findOne({ actorUrl });
630
- if (following) {
631
- try {
632
- const timelineItem = await extractObjectData(object, {
633
- actorFallback: actorObj,
634
- documentLoader: authLoader,
635
- });
636
- await addTimelineItem(collections, timelineItem);
637
-
638
- // Fire-and-forget OG unfurling for notes and articles (not boosts)
639
- if (timelineItem.type === "note" || timelineItem.type === "article") {
640
- fetchAndStorePreviews(collections, timelineItem.uid, timelineItem.content.html)
641
- .catch((error) => {
642
- console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error);
643
- });
644
- }
645
-
646
- // Fire-and-forget quote enrichment
647
- if (timelineItem.quoteUrl) {
648
- fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
649
- .catch((error) => {
650
- console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
651
- });
652
- }
653
- } catch (error) {
654
- // Log extraction errors but don't fail the entire handler
655
- console.error("Failed to store timeline item:", error);
656
- }
657
- } else if (collections.ap_followed_tags) {
658
- // Not a followed account — check if the post's hashtags match any followed tags
659
- // so tagged posts from across the fediverse appear in the timeline
660
- try {
661
- const objectTags = Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []);
662
- const postHashtags = objectTags
663
- .filter((t) => t.type === "Hashtag" && t.name)
664
- .map((t) => t.name.toString().replace(/^#/, "").toLowerCase());
665
-
666
- if (postHashtags.length > 0) {
667
- const followedTags = await getFollowedTags(collections);
668
- const followedSet = new Set(followedTags.map((t) => t.toLowerCase()));
669
- const hasMatchingTag = postHashtags.some((tag) => followedSet.has(tag));
670
-
671
- if (hasMatchingTag) {
672
- const timelineItem = await extractObjectData(object, {
673
- actorFallback: actorObj,
674
- documentLoader: authLoader,
675
- });
676
- await addTimelineItem(collections, timelineItem);
677
- }
678
- }
679
- } catch (error) {
680
- // Non-critical — don't fail the handler
681
- console.error("[inbox] Followed tag check failed:", error.message);
682
- }
683
- }
684
-
266
+ await enqueueActivity(collections, {
267
+ activityType: "Create",
268
+ actorUrl,
269
+ objectUrl,
270
+ rawJson: await create.toJsonLd(),
271
+ });
685
272
  })
273
+
274
+ // ── Delete ──────────────────────────────────────────────────────
686
275
  .on(Delete, async (ctx, del) => {
687
- const objectId = del.objectId?.href || "";
688
- if (objectId) {
689
- // Remove from activity log
690
- await collections.ap_activities.deleteMany({ objectUrl: objectId });
276
+ const actorUrl = del.actorId?.href || "";
277
+ if (await isServerBlocked(actorUrl, collections)) return;
278
+ await touchKeyFreshness(collections, actorUrl);
279
+ await resetDeliveryStrikes(collections, actorUrl);
691
280
 
692
- // Remove from timeline
693
- await deleteTimelineItem(collections, objectId);
694
- }
281
+ await enqueueActivity(collections, {
282
+ activityType: "Delete",
283
+ actorUrl,
284
+ objectUrl: del.objectId?.href || "",
285
+ rawJson: await del.toJsonLd(),
286
+ });
695
287
  })
696
- .on(Move, async (ctx, move) => {
697
- const authLoader = await getAuthLoader(ctx);
698
- const oldActorObj = await move.getActor({ documentLoader: authLoader });
699
- const oldActorUrl = oldActorObj?.id?.href || "";
700
- const target = await move.getTarget({ documentLoader: authLoader });
701
- const newActorUrl = target?.id?.href || "";
702
288
 
703
- if (oldActorUrl && newActorUrl) {
704
- await collections.ap_followers.updateOne(
705
- { actorUrl: oldActorUrl },
706
- { $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
707
- );
708
- }
289
+ // ── Move ────────────────────────────────────────────────────────
290
+ .on(Move, async (ctx, move) => {
291
+ const actorUrl = move.actorId?.href || "";
292
+ if (await isServerBlocked(actorUrl, collections)) return;
293
+ await touchKeyFreshness(collections, actorUrl);
294
+ await resetDeliveryStrikes(collections, actorUrl);
709
295
 
710
- await logActivity(collections, storeRawActivities, {
711
- direction: "inbound",
712
- type: "Move",
713
- actorUrl: oldActorUrl,
714
- objectUrl: newActorUrl,
715
- summary: `${oldActorUrl} moved to ${newActorUrl}`,
296
+ await enqueueActivity(collections, {
297
+ activityType: "Move",
298
+ actorUrl,
299
+ rawJson: await move.toJsonLd(),
716
300
  });
717
301
  })
718
- .on(Update, async (ctx, update) => {
719
- // Update can be for a profile OR for a post (edited content)
720
- const authLoader = await getAuthLoader(ctx);
721
302
 
722
- // Try to get the object being updated
723
- let object;
724
- try {
725
- object = await update.getObject({ documentLoader: authLoader });
726
- } catch {
727
- object = null;
728
- }
729
-
730
- // PATH 1: If object is a Note/Article → Update timeline item content
731
- if (object && (object instanceof Note || object instanceof Article)) {
732
- const objectUrl = object.id?.href || "";
733
- if (objectUrl) {
734
- try {
735
- // Extract updated content
736
- const contentHtml = object.content?.toString() || "";
737
- const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
738
-
739
- const updates = {
740
- content: {
741
- text: contentText,
742
- html: contentHtml,
743
- },
744
- name: object.name?.toString() || "",
745
- summary: object.summary?.toString() || "",
746
- sensitive: object.sensitive || false,
747
- };
748
-
749
- await updateTimelineItem(collections, objectUrl, updates);
750
- } catch (error) {
751
- console.error("Failed to update timeline item:", error);
752
- }
753
- }
754
- return;
755
- }
756
-
757
- // PATH 2: Otherwise, assume profile update — refresh stored follower data
758
- const actorObj = await update.getActor({ documentLoader: authLoader });
759
- const actorUrl = actorObj?.id?.href || "";
760
- if (!actorUrl) return;
303
+ // ── Update ──────────────────────────────────────────────────────
304
+ .on(Update, async (ctx, update) => {
305
+ const actorUrl = update.actorId?.href || "";
306
+ if (await isServerBlocked(actorUrl, collections)) return;
307
+ await touchKeyFreshness(collections, actorUrl);
308
+ await resetDeliveryStrikes(collections, actorUrl);
761
309
 
762
- const existing = await collections.ap_followers.findOne({ actorUrl });
763
- if (existing) {
764
- await collections.ap_followers.updateOne(
765
- { actorUrl },
766
- {
767
- $set: {
768
- name:
769
- actorObj.name?.toString() ||
770
- actorObj.preferredUsername?.toString() ||
771
- actorUrl,
772
- handle: actorObj.preferredUsername?.toString() || "",
773
- avatar: actorObj.icon
774
- ? (await actorObj.icon)?.url?.href || ""
775
- : "",
776
- updatedAt: new Date().toISOString(),
777
- },
778
- },
779
- );
780
- }
310
+ await enqueueActivity(collections, {
311
+ activityType: "Update",
312
+ actorUrl,
313
+ rawJson: await update.toJsonLd(),
314
+ });
781
315
  })
316
+
317
+ // ── Block ───────────────────────────────────────────────────────
318
+ // Synchronous: remove from followers (immediate)
319
+ // Async: activity log
782
320
  .on(Block, async (ctx, block) => {
783
- // Remote actor blocked us — remove them from followers
321
+ const actorUrl = block.actorId?.href || "";
322
+ if (await isServerBlocked(actorUrl, collections)) return;
323
+
324
+ // Synchronous: remove from followers immediately
784
325
  const authLoader = await getAuthLoader(ctx);
785
326
  const actorObj = await block.getActor({ documentLoader: authLoader });
786
- const actorUrl = actorObj?.id?.href || "";
787
- if (actorUrl) {
788
- await collections.ap_followers.deleteOne({ actorUrl });
327
+ const resolvedUrl = actorObj?.id?.href || "";
328
+ if (resolvedUrl) {
329
+ await collections.ap_followers.deleteOne({ actorUrl: resolvedUrl });
789
330
  }
790
- })
791
- .on(Add, async () => {
792
- // Mastodon uses Add for pinning posts to featured collections — safe to ignore
793
- })
794
- .on(Remove, async () => {
795
- // Mastodon uses Remove for unpinning posts from featured collections — safe to ignore
796
- })
797
- // ── Flag (Report) ──────────────────────────────────────────────
798
- .on(Flag, async (ctx, flag) => {
799
- try {
800
- const authLoader = await getAuthLoader(ctx);
801
- const actorObj = await flag.getActor({ documentLoader: authLoader }).catch(() => null);
802
-
803
- const reporterUrl = actorObj?.id?.href || flag.actorId?.href || "";
804
- const reporterName = actorObj?.name?.toString() || actorObj?.preferredUsername?.toString() || reporterUrl;
805
-
806
- // Extract reported objects — Flag can report actors or posts
807
- const reportedIds = flag.objectIds?.map((u) => u.href) || [];
808
- const reason = flag.content?.toString() || flag.summary?.toString() || "";
809
-
810
- if (reportedIds.length === 0 && !reason) {
811
- console.info("[ActivityPub] Ignoring empty Flag from", reporterUrl);
812
- return;
813
- }
814
331
 
815
- // Store report
816
- if (collections.ap_reports) {
817
- await collections.ap_reports.insertOne({
818
- reporterUrl,
819
- reporterName,
820
- reportedUrls: reportedIds,
821
- reason,
822
- createdAt: new Date().toISOString(),
823
- read: false,
824
- });
825
- }
332
+ await enqueueActivity(collections, {
333
+ activityType: "Block",
334
+ actorUrl: resolvedUrl || actorUrl,
335
+ rawJson: await block.toJsonLd(),
336
+ });
337
+ })
826
338
 
827
- // Create notification
828
- if (collections.ap_notifications) {
829
- await addNotification(collections, {
830
- uid: `flag:${reporterUrl}:${Date.now()}`,
831
- type: "report",
832
- actorUrl: reporterUrl,
833
- actorName: reporterName,
834
- actorPhoto: actorObj?.iconUrl?.href || actorObj?.icon?.url?.href || "",
835
- actorHandle: actorObj?.preferredUsername
836
- ? `@${actorObj.preferredUsername}@${new URL(reporterUrl).hostname}`
837
- : reporterUrl,
838
- objectUrl: reportedIds[0] || "",
839
- summary: reason ? reason.slice(0, 200) : "Report received",
840
- published: new Date().toISOString(),
841
- createdAt: new Date().toISOString(),
842
- });
843
- }
339
+ // ── Add / Remove (no-ops) ───────────────────────────────────────
340
+ .on(Add, async () => {})
341
+ .on(Remove, async () => {})
844
342
 
845
- await logActivity(collections, storeRawActivities, {
846
- direction: "inbound",
847
- type: "Flag",
848
- actorUrl: reporterUrl,
849
- objectUrl: reportedIds[0] || "",
850
- summary: `Report from ${reporterName}: ${reason.slice(0, 100)}`,
851
- });
343
+ // ── Flag ────────────────────────────────────────────────────────
344
+ .on(Flag, async (ctx, flag) => {
345
+ const actorUrl = flag.actorId?.href || "";
346
+ if (await isServerBlocked(actorUrl, collections)) return;
347
+ await touchKeyFreshness(collections, actorUrl);
348
+ await resetDeliveryStrikes(collections, actorUrl);
852
349
 
853
- console.info(`[ActivityPub] Flag received from ${reporterName} — ${reportedIds.length} objects reported`);
854
- } catch (error) {
855
- console.warn("[ActivityPub] Flag handler error:", error.message);
856
- }
350
+ await enqueueActivity(collections, {
351
+ activityType: "Flag",
352
+ actorUrl,
353
+ rawJson: await flag.toJsonLd(),
354
+ });
857
355
  });
858
356
  }
859
-
860
- /**
861
- * Log an activity to the ap_activities collection.
862
- * Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
863
- * used throughout this file.
864
- */
865
- async function logActivity(collections, storeRaw, record, rawJson) {
866
- await logActivityShared(
867
- collections.ap_activities,
868
- record,
869
- storeRaw && rawJson ? { rawJson } : {},
870
- );
871
- }
872
-