@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 +68 -0
- package/lib/controllers/moderation.js +80 -1
- package/lib/federation-setup.js +49 -29
- package/lib/inbox-handlers.js +1021 -0
- package/lib/inbox-listeners.js +147 -710
- package/lib/inbox-queue.js +99 -0
- package/lib/key-refresh.js +138 -0
- package/lib/redis-cache.js +16 -0
- package/lib/storage/server-blocks.js +121 -0
- package/package.json +1 -1
- package/views/activitypub-moderation.njk +77 -0
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,
|
package/lib/federation-setup.js
CHANGED
|
@@ -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
|
|
408
|
-
.
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
426
|
-
.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
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
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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
|
|
489
|
-
.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
514
|
-
|
|
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");
|