@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 +68 -0
- package/lib/controllers/moderation.js +80 -1
- package/lib/federation-setup.js +57 -46
- package/lib/inbox-handlers.js +1102 -0
- package/lib/inbox-listeners.js +188 -704
- package/lib/inbox-queue.js +99 -0
- package/lib/key-refresh.js +138 -0
- package/lib/outbox-failure.js +139 -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,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
|
|
343
|
-
//
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
408
|
-
.
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
426
|
-
.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
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
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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
|
|
489
|
-
.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
514
|
-
|
|
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");
|