@rmdes/indiekit-endpoint-activitypub 2.13.0 → 2.14.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,9 @@ 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
+
45
+ const COLLECTION_CACHE_TTL = 300; // 5 minutes
43
46
 
44
47
  /**
45
48
  * Create and configure a Fedify Federation instance.
@@ -404,10 +407,12 @@ function setupFollowers(federation, mountPath, handle, collections) {
404
407
  // as Recipient objects so sendActivity("followers") can deliver.
405
408
  // See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients
406
409
  if (cursor == null) {
407
- const docs = await collections.ap_followers
408
- .find()
409
- .sort({ followedAt: -1 })
410
- .toArray();
410
+ const docs = await cachedQuery("col:followers:recipients", COLLECTION_CACHE_TTL, async () => {
411
+ return await collections.ap_followers
412
+ .find()
413
+ .sort({ followedAt: -1 })
414
+ .toArray();
415
+ });
411
416
  return {
412
417
  items: docs.map((f) => ({
413
418
  id: new URL(f.actorUrl),
@@ -422,13 +427,16 @@ function setupFollowers(federation, mountPath, handle, collections) {
422
427
  // Paginated collection: for remote browsing of /followers endpoint
423
428
  const pageSize = 20;
424
429
  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();
430
+ const [docs, total] = await cachedQuery(`col:followers:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
431
+ const d = await collections.ap_followers
432
+ .find()
433
+ .sort({ followedAt: -1 })
434
+ .skip(skip)
435
+ .limit(pageSize)
436
+ .toArray();
437
+ const t = await collections.ap_followers.countDocuments();
438
+ return [d, t];
439
+ });
432
440
 
433
441
  return {
434
442
  items: docs.map((f) => new URL(f.actorUrl)),
@@ -439,7 +447,9 @@ function setupFollowers(federation, mountPath, handle, collections) {
439
447
  )
440
448
  .setCounter(async (ctx, identifier) => {
441
449
  if (identifier !== handle) return 0;
442
- return await collections.ap_followers.countDocuments();
450
+ return await cachedQuery("col:followers:count", COLLECTION_CACHE_TTL, async () => {
451
+ return await collections.ap_followers.countDocuments();
452
+ });
443
453
  })
444
454
  .setFirstCursor(async () => "0");
445
455
  }
@@ -452,13 +462,16 @@ function setupFollowing(federation, mountPath, handle, collections) {
452
462
  if (identifier !== handle) return null;
453
463
  const pageSize = 20;
454
464
  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();
465
+ const [docs, total] = await cachedQuery(`col:following:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
466
+ const d = await collections.ap_following
467
+ .find()
468
+ .sort({ followedAt: -1 })
469
+ .skip(skip)
470
+ .limit(pageSize)
471
+ .toArray();
472
+ const t = await collections.ap_following.countDocuments();
473
+ return [d, t];
474
+ });
462
475
 
463
476
  return {
464
477
  items: docs.map((f) => new URL(f.actorUrl)),
@@ -469,7 +482,9 @@ function setupFollowing(federation, mountPath, handle, collections) {
469
482
  )
470
483
  .setCounter(async (ctx, identifier) => {
471
484
  if (identifier !== handle) return 0;
472
- return await collections.ap_following.countDocuments();
485
+ return await cachedQuery("col:following:count", COLLECTION_CACHE_TTL, async () => {
486
+ return await collections.ap_following.countDocuments();
487
+ });
473
488
  })
474
489
  .setFirstCursor(async () => "0");
475
490
  }
@@ -485,13 +500,16 @@ function setupLiked(federation, mountPath, handle, collections) {
485
500
  const pageSize = 20;
486
501
  const skip = cursor ? Number.parseInt(cursor, 10) : 0;
487
502
  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);
503
+ const [docs, total] = await cachedQuery(`col:liked:page:${cursor}`, COLLECTION_CACHE_TTL, async () => {
504
+ const d = await collections.posts
505
+ .find(query)
506
+ .sort({ "properties.published": -1 })
507
+ .skip(skip)
508
+ .limit(pageSize)
509
+ .toArray();
510
+ const t = await collections.posts.countDocuments(query);
511
+ return [d, t];
512
+ });
495
513
 
496
514
  const items = docs
497
515
  .map((d) => {
@@ -510,8 +528,10 @@ function setupLiked(federation, mountPath, handle, collections) {
510
528
  .setCounter(async (ctx, identifier) => {
511
529
  if (identifier !== handle) return 0;
512
530
  if (!collections.posts) return 0;
513
- return await collections.posts.countDocuments({
514
- "properties.post-type": "like",
531
+ return await cachedQuery("col:liked:count", COLLECTION_CACHE_TTL, async () => {
532
+ return await collections.posts.countDocuments({
533
+ "properties.post-type": "like",
534
+ });
515
535
  });
516
536
  })
517
537
  .setFirstCursor(async () => "0");