@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.
package/index.js CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  unmuteController,
37
37
  blockController,
38
38
  unblockController,
39
+ blockServerController,
40
+ unblockServerController,
39
41
  moderationController,
40
42
  filterModeController,
41
43
  } from "./lib/controllers/moderation.js";
@@ -103,6 +105,9 @@ import { startBatchRefollow } from "./lib/batch-refollow.js";
103
105
  import { logActivity } from "./lib/activity-log.js";
104
106
  import { scheduleCleanup } from "./lib/timeline-cleanup.js";
105
107
  import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
108
+ import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
109
+ import { scheduleKeyRefresh } from "./lib/key-refresh.js";
110
+ import { startInboxProcessor } from "./lib/inbox-queue.js";
106
111
  import { deleteFederationController } from "./lib/controllers/federation-delete.js";
107
112
  import {
108
113
  federationMgmtController,
@@ -308,6 +313,8 @@ export default class ActivityPubEndpoint {
308
313
  router.post("/admin/reader/unmute", unmuteController(mp, this));
309
314
  router.post("/admin/reader/block", blockController(mp, this));
310
315
  router.post("/admin/reader/unblock", unblockController(mp, this));
316
+ router.post("/admin/reader/block-server", blockServerController(mp));
317
+ router.post("/admin/reader/unblock-server", unblockServerController(mp));
311
318
  router.get("/admin/followers", followersController(mp));
312
319
  router.post("/admin/followers/approve", approveFollowController(mp, this));
313
320
  router.post("/admin/followers/reject", rejectFollowController(mp, this));
@@ -1124,6 +1131,12 @@ export default class ActivityPubEndpoint {
1124
1131
  Indiekit.addCollection("ap_reports");
1125
1132
  // Pending follow requests (manual approval)
1126
1133
  Indiekit.addCollection("ap_pending_follows");
1134
+ // Server-level blocks
1135
+ Indiekit.addCollection("ap_blocked_servers");
1136
+ // Key freshness tracking for proactive refresh
1137
+ Indiekit.addCollection("ap_key_freshness");
1138
+ // Async inbox processing queue
1139
+ Indiekit.addCollection("ap_inbox_queue");
1127
1140
 
1128
1141
  // Store collection references (posts resolved lazily)
1129
1142
  const indiekitCollections = Indiekit.collections;
@@ -1151,6 +1164,12 @@ export default class ActivityPubEndpoint {
1151
1164
  ap_reports: indiekitCollections.get("ap_reports"),
1152
1165
  // Pending follow requests (manual approval)
1153
1166
  ap_pending_follows: indiekitCollections.get("ap_pending_follows"),
1167
+ // Server-level blocks
1168
+ ap_blocked_servers: indiekitCollections.get("ap_blocked_servers"),
1169
+ // Key freshness tracking
1170
+ ap_key_freshness: indiekitCollections.get("ap_key_freshness"),
1171
+ // Async inbox processing queue
1172
+ ap_inbox_queue: indiekitCollections.get("ap_inbox_queue"),
1154
1173
  get posts() {
1155
1174
  return indiekitCollections.get("posts");
1156
1175
  },
@@ -1351,6 +1370,27 @@ export default class ActivityPubEndpoint {
1351
1370
  { requestedAt: -1 },
1352
1371
  { background: true },
1353
1372
  );
1373
+ // Server-level blocks
1374
+ this._collections.ap_blocked_servers.createIndex(
1375
+ { hostname: 1 },
1376
+ { unique: true, background: true },
1377
+ );
1378
+ // Key freshness tracking
1379
+ this._collections.ap_key_freshness.createIndex(
1380
+ { actorUrl: 1 },
1381
+ { unique: true, background: true },
1382
+ );
1383
+
1384
+ // Inbox queue indexes
1385
+ this._collections.ap_inbox_queue.createIndex(
1386
+ { status: 1, receivedAt: 1 },
1387
+ { background: true },
1388
+ );
1389
+ // TTL: auto-prune completed items after 24h
1390
+ this._collections.ap_inbox_queue.createIndex(
1391
+ { processedAt: 1 },
1392
+ { expireAfterSeconds: 86_400, background: true },
1393
+ );
1354
1394
  } catch {
1355
1395
  // Index creation failed — collections not yet available.
1356
1396
  // Indexes already exist from previous startups; non-fatal.
@@ -1446,6 +1486,34 @@ export default class ActivityPubEndpoint {
1446
1486
  if (this.options.timelineRetention > 0) {
1447
1487
  scheduleCleanup(this._collections, this.options.timelineRetention);
1448
1488
  }
1489
+
1490
+ // Load server blocks into Redis for fast inbox checks
1491
+ loadBlockedServersToRedis(this._collections).catch((error) => {
1492
+ console.warn("[ActivityPub] Failed to load blocked servers to Redis:", error.message);
1493
+ });
1494
+
1495
+ // Schedule proactive key refresh for stale follower keys (runs on startup + every 24h)
1496
+ const keyRefreshHandle = this.options.actor.handle;
1497
+ const keyRefreshFederation = this._federation;
1498
+ const keyRefreshPubUrl = this._publicationUrl;
1499
+ scheduleKeyRefresh(
1500
+ this._collections,
1501
+ () => keyRefreshFederation?.createContext(new URL(keyRefreshPubUrl), {
1502
+ handle: keyRefreshHandle,
1503
+ publicationUrl: keyRefreshPubUrl,
1504
+ }),
1505
+ keyRefreshHandle,
1506
+ );
1507
+
1508
+ // Start async inbox queue processor (processes one item every 3s)
1509
+ this._inboxProcessorInterval = startInboxProcessor(
1510
+ this._collections,
1511
+ () => this._federation?.createContext(new URL(this._publicationUrl), {
1512
+ handle: this.options.actor.handle,
1513
+ publicationUrl: this._publicationUrl,
1514
+ }),
1515
+ this.options.actor.handle,
1516
+ );
1449
1517
  }
1450
1518
 
1451
1519
  /**
@@ -14,6 +14,11 @@ import {
14
14
  getFilterMode,
15
15
  setFilterMode,
16
16
  } from "../storage/moderation.js";
17
+ import {
18
+ addBlockedServer,
19
+ removeBlockedServer,
20
+ getAllBlockedServers,
21
+ } from "../storage/server-blocks.js";
17
22
 
18
23
  /**
19
24
  * Helper to get moderation collections from request.
@@ -23,6 +28,7 @@ function getModerationCollections(request) {
23
28
  return {
24
29
  ap_muted: application?.collections?.get("ap_muted"),
25
30
  ap_blocked: application?.collections?.get("ap_blocked"),
31
+ ap_blocked_servers: application?.collections?.get("ap_blocked_servers"),
26
32
  ap_timeline: application?.collections?.get("ap_timeline"),
27
33
  ap_profile: application?.collections?.get("ap_profile"),
28
34
  };
@@ -282,6 +288,77 @@ export function unblockController(mountPath, plugin) {
282
288
  };
283
289
  }
284
290
 
291
+ /**
292
+ * POST /admin/reader/block-server — Block a server by hostname.
293
+ */
294
+ export function blockServerController(mountPath) {
295
+ return async (request, response, next) => {
296
+ try {
297
+ if (!validateToken(request)) {
298
+ return response.status(403).json({
299
+ success: false,
300
+ error: "Invalid CSRF token",
301
+ });
302
+ }
303
+
304
+ const { hostname, reason } = request.body;
305
+ if (!hostname) {
306
+ return response.status(400).json({
307
+ success: false,
308
+ error: "Missing hostname",
309
+ });
310
+ }
311
+
312
+ const collections = getModerationCollections(request);
313
+ await addBlockedServer(collections, hostname, reason);
314
+
315
+ console.info(`[ActivityPub] Blocked server: ${hostname}`);
316
+ return response.json({ success: true, type: "block-server", hostname });
317
+ } catch (error) {
318
+ console.error("[ActivityPub] Block server failed:", error.message);
319
+ return response.status(500).json({
320
+ success: false,
321
+ error: "Operation failed. Please try again later.",
322
+ });
323
+ }
324
+ };
325
+ }
326
+
327
+ /**
328
+ * POST /admin/reader/unblock-server — Unblock a server.
329
+ */
330
+ export function unblockServerController(mountPath) {
331
+ return async (request, response, next) => {
332
+ try {
333
+ if (!validateToken(request)) {
334
+ return response.status(403).json({
335
+ success: false,
336
+ error: "Invalid CSRF token",
337
+ });
338
+ }
339
+
340
+ const { hostname } = request.body;
341
+ if (!hostname) {
342
+ return response.status(400).json({
343
+ success: false,
344
+ error: "Missing hostname",
345
+ });
346
+ }
347
+
348
+ const collections = getModerationCollections(request);
349
+ await removeBlockedServer(collections, hostname);
350
+
351
+ console.info(`[ActivityPub] Unblocked server: ${hostname}`);
352
+ return response.json({ success: true, type: "unblock-server", hostname });
353
+ } catch (error) {
354
+ return response.status(500).json({
355
+ success: false,
356
+ error: "Operation failed. Please try again later.",
357
+ });
358
+ }
359
+ };
360
+ }
361
+
285
362
  /**
286
363
  * GET /admin/reader/moderation — View muted/blocked lists.
287
364
  */
@@ -291,9 +368,10 @@ export function moderationController(mountPath) {
291
368
  const collections = getModerationCollections(request);
292
369
  const csrfToken = getToken(request.session);
293
370
 
294
- const [muted, blocked, filterMode] = await Promise.all([
371
+ const [muted, blocked, blockedServers, filterMode] = await Promise.all([
295
372
  getAllMuted(collections),
296
373
  getAllBlocked(collections),
374
+ getAllBlockedServers(collections),
297
375
  getFilterMode(collections),
298
376
  ]);
299
377
 
@@ -305,6 +383,7 @@ export function moderationController(mountPath) {
305
383
  readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
306
384
  muted,
307
385
  blocked,
386
+ blockedServers,
308
387
  mutedActors,
309
388
  mutedKeywords,
310
389
  filterMode,
@@ -40,6 +40,10 @@ import Redis from "ioredis";
40
40
  import { MongoKvStore } from "./kv-store.js";
41
41
  import { registerInboxListeners } from "./inbox-listeners.js";
42
42
  import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js";
43
+ import { cachedQuery } from "./redis-cache.js";
44
+ import { onOutboxPermanentFailure } from "./outbox-failure.js";
45
+
46
+ const COLLECTION_CACHE_TTL = 300; // 5 minutes
43
47
 
44
48
  /**
45
49
  * Create and configure a Fedify Federation instance.
@@ -339,25 +343,15 @@ export function setupFederation(options) {
339
343
  });
340
344
 
341
345
  // Handle permanent delivery failures (Fedify 2.0).
342
- // Fires when a remote inbox returns 404/410 — the server is gone.
343
- // Log it and let the admin see which followers are unreachable.
346
+ // Fires when a remote inbox returns 404/410.
347
+ // 410: immediate full cleanup. 404: strike system (3 strikes over 7 days).
344
348
  federation.setOutboxPermanentFailureHandler(async (_ctx, values) => {
345
- const { inbox, error, actorIds } = values;
346
- const inboxUrl = inbox?.href || String(inbox);
347
- const actors = actorIds?.map((id) => id?.href || String(id)) || [];
348
- console.warn(
349
- `[ActivityPub] Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}` +
350
- (actors.length ? ` (actors: ${actors.join(", ")})` : ""),
349
+ await onOutboxPermanentFailure(
350
+ values.statusCode,
351
+ values.actorIds,
352
+ values.inbox,
353
+ collections,
351
354
  );
352
- collections.ap_activities.insertOne({
353
- direction: "outbound",
354
- type: "DeliveryFailed",
355
- actorUrl: publicationUrl,
356
- objectUrl: inboxUrl,
357
- summary: `Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}`,
358
- affectedActors: actors,
359
- receivedAt: new Date().toISOString(),
360
- }).catch(() => {});
361
355
  });
362
356
 
363
357
  // Wrap with debug dashboard if enabled. The debugger proxies the
@@ -404,10 +398,12 @@ function setupFollowers(federation, mountPath, handle, collections) {
404
398
  // as Recipient objects so sendActivity("followers") can deliver.
405
399
  // See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients
406
400
  if (cursor == null) {
407
- const docs = await collections.ap_followers
408
- .find()
409
- .sort({ followedAt: -1 })
410
- .toArray();
401
+ const docs = await cachedQuery("col:followers:recipients", COLLECTION_CACHE_TTL, async () => {
402
+ return await collections.ap_followers
403
+ .find()
404
+ .sort({ followedAt: -1 })
405
+ .toArray();
406
+ });
411
407
  return {
412
408
  items: docs.map((f) => ({
413
409
  id: new URL(f.actorUrl),
@@ -422,13 +418,16 @@ function setupFollowers(federation, mountPath, handle, collections) {
422
418
  // Paginated collection: for remote browsing of /followers endpoint
423
419
  const pageSize = 20;
424
420
  const skip = Number.parseInt(cursor, 10);
425
- const docs = await collections.ap_followers
426
- .find()
427
- .sort({ followedAt: -1 })
428
- .skip(skip)
429
- .limit(pageSize)
430
- .toArray();
431
- const total = await collections.ap_followers.countDocuments();
421
+ const [docs, total] = await cachedQuery(`col:followers:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
422
+ const d = await collections.ap_followers
423
+ .find()
424
+ .sort({ followedAt: -1 })
425
+ .skip(skip)
426
+ .limit(pageSize)
427
+ .toArray();
428
+ const t = await collections.ap_followers.countDocuments();
429
+ return [d, t];
430
+ });
432
431
 
433
432
  return {
434
433
  items: docs.map((f) => new URL(f.actorUrl)),
@@ -439,7 +438,9 @@ function setupFollowers(federation, mountPath, handle, collections) {
439
438
  )
440
439
  .setCounter(async (ctx, identifier) => {
441
440
  if (identifier !== handle) return 0;
442
- return await collections.ap_followers.countDocuments();
441
+ return await cachedQuery("col:followers:count", COLLECTION_CACHE_TTL, async () => {
442
+ return await collections.ap_followers.countDocuments();
443
+ });
443
444
  })
444
445
  .setFirstCursor(async () => "0");
445
446
  }
@@ -452,13 +453,16 @@ function setupFollowing(federation, mountPath, handle, collections) {
452
453
  if (identifier !== handle) return null;
453
454
  const pageSize = 20;
454
455
  const skip = cursor ? Number.parseInt(cursor, 10) : 0;
455
- const docs = await collections.ap_following
456
- .find()
457
- .sort({ followedAt: -1 })
458
- .skip(skip)
459
- .limit(pageSize)
460
- .toArray();
461
- const total = await collections.ap_following.countDocuments();
456
+ const [docs, total] = await cachedQuery(`col:following:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
457
+ const d = await collections.ap_following
458
+ .find()
459
+ .sort({ followedAt: -1 })
460
+ .skip(skip)
461
+ .limit(pageSize)
462
+ .toArray();
463
+ const t = await collections.ap_following.countDocuments();
464
+ return [d, t];
465
+ });
462
466
 
463
467
  return {
464
468
  items: docs.map((f) => new URL(f.actorUrl)),
@@ -469,7 +473,9 @@ function setupFollowing(federation, mountPath, handle, collections) {
469
473
  )
470
474
  .setCounter(async (ctx, identifier) => {
471
475
  if (identifier !== handle) return 0;
472
- return await collections.ap_following.countDocuments();
476
+ return await cachedQuery("col:following:count", COLLECTION_CACHE_TTL, async () => {
477
+ return await collections.ap_following.countDocuments();
478
+ });
473
479
  })
474
480
  .setFirstCursor(async () => "0");
475
481
  }
@@ -485,13 +491,16 @@ function setupLiked(federation, mountPath, handle, collections) {
485
491
  const pageSize = 20;
486
492
  const skip = cursor ? Number.parseInt(cursor, 10) : 0;
487
493
  const query = { "properties.post-type": "like" };
488
- const docs = await collections.posts
489
- .find(query)
490
- .sort({ "properties.published": -1 })
491
- .skip(skip)
492
- .limit(pageSize)
493
- .toArray();
494
- const total = await collections.posts.countDocuments(query);
494
+ const [docs, total] = await cachedQuery(`col:liked:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
495
+ const d = await collections.posts
496
+ .find(query)
497
+ .sort({ "properties.published": -1 })
498
+ .skip(skip)
499
+ .limit(pageSize)
500
+ .toArray();
501
+ const t = await collections.posts.countDocuments(query);
502
+ return [d, t];
503
+ });
495
504
 
496
505
  const items = docs
497
506
  .map((d) => {
@@ -510,8 +519,10 @@ function setupLiked(federation, mountPath, handle, collections) {
510
519
  .setCounter(async (ctx, identifier) => {
511
520
  if (identifier !== handle) return 0;
512
521
  if (!collections.posts) return 0;
513
- return await collections.posts.countDocuments({
514
- "properties.post-type": "like",
522
+ return await cachedQuery("col:liked:count", COLLECTION_CACHE_TTL, async () => {
523
+ return await collections.posts.countDocuments({
524
+ "properties.post-type": "like",
525
+ });
515
526
  });
516
527
  })
517
528
  .setFirstCursor(async () => "0");