@rmdes/indiekit-endpoint-activitypub 2.12.1 → 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/assets/reader.css +25 -0
- package/index.js +93 -5
- package/lib/batch-refollow.js +3 -1
- package/lib/controllers/compose.js +2 -1
- package/lib/controllers/federation-mgmt.js +42 -3
- package/lib/controllers/follow-requests.js +253 -0
- package/lib/controllers/followers.js +54 -3
- package/lib/controllers/interactions-boost.js +1 -0
- package/lib/controllers/messages.js +3 -2
- package/lib/controllers/moderation.js +83 -3
- package/lib/controllers/post-detail.js +5 -4
- package/lib/controllers/profile.remote.js +2 -1
- package/lib/controllers/resolve.js +2 -1
- package/lib/federation-setup.js +49 -29
- package/lib/inbox-handlers.js +1021 -0
- package/lib/inbox-listeners.js +216 -734
- package/lib/inbox-queue.js +99 -0
- package/lib/jf2-to-as2.js +2 -2
- package/lib/key-refresh.js +138 -0
- package/lib/lookup-helpers.js +27 -0
- package/lib/og-unfurl.js +3 -2
- package/lib/redis-cache.js +16 -0
- package/lib/resolve-author.js +5 -3
- package/lib/storage/notifications.js +5 -0
- package/lib/storage/server-blocks.js +121 -0
- package/lib/timeline-store.js +31 -1
- package/locales/en.json +7 -0
- package/package.json +1 -1
- package/views/activitypub-followers.njk +60 -12
- package/views/activitypub-moderation.njk +77 -0
- package/views/partials/ap-notification-card.njk +3 -1
package/assets/reader.css
CHANGED
|
@@ -3408,3 +3408,28 @@
|
|
|
3408
3408
|
}
|
|
3409
3409
|
}
|
|
3410
3410
|
|
|
3411
|
+
/* Follow request approve/reject actions */
|
|
3412
|
+
.ap-follow-request {
|
|
3413
|
+
margin-block-end: var(--space-m);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
.ap-follow-request__actions {
|
|
3417
|
+
display: flex;
|
|
3418
|
+
gap: var(--space-s);
|
|
3419
|
+
margin-block-start: var(--space-xs);
|
|
3420
|
+
padding-inline-start: var(--space-l);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
.ap-follow-request__form {
|
|
3424
|
+
display: inline;
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
.button--danger {
|
|
3428
|
+
background-color: var(--color-red45);
|
|
3429
|
+
color: white;
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
.button--danger:hover {
|
|
3433
|
+
background-color: var(--color-red35, #c0392b);
|
|
3434
|
+
}
|
|
3435
|
+
|
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import express from "express";
|
|
|
2
2
|
|
|
3
3
|
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
|
|
4
4
|
import { initRedisCache } from "./lib/redis-cache.js";
|
|
5
|
+
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
|
|
5
6
|
import {
|
|
6
7
|
createFedifyMiddleware,
|
|
7
8
|
} from "./lib/federation-bridge.js";
|
|
@@ -35,10 +36,16 @@ import {
|
|
|
35
36
|
unmuteController,
|
|
36
37
|
blockController,
|
|
37
38
|
unblockController,
|
|
39
|
+
blockServerController,
|
|
40
|
+
unblockServerController,
|
|
38
41
|
moderationController,
|
|
39
42
|
filterModeController,
|
|
40
43
|
} from "./lib/controllers/moderation.js";
|
|
41
44
|
import { followersController } from "./lib/controllers/followers.js";
|
|
45
|
+
import {
|
|
46
|
+
approveFollowController,
|
|
47
|
+
rejectFollowController,
|
|
48
|
+
} from "./lib/controllers/follow-requests.js";
|
|
42
49
|
import { followingController } from "./lib/controllers/following.js";
|
|
43
50
|
import { activitiesController } from "./lib/controllers/activities.js";
|
|
44
51
|
import {
|
|
@@ -98,6 +105,9 @@ import { startBatchRefollow } from "./lib/batch-refollow.js";
|
|
|
98
105
|
import { logActivity } from "./lib/activity-log.js";
|
|
99
106
|
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
|
100
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";
|
|
101
111
|
import { deleteFederationController } from "./lib/controllers/federation-delete.js";
|
|
102
112
|
import {
|
|
103
113
|
federationMgmtController,
|
|
@@ -303,7 +313,11 @@ export default class ActivityPubEndpoint {
|
|
|
303
313
|
router.post("/admin/reader/unmute", unmuteController(mp, this));
|
|
304
314
|
router.post("/admin/reader/block", blockController(mp, this));
|
|
305
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));
|
|
306
318
|
router.get("/admin/followers", followersController(mp));
|
|
319
|
+
router.post("/admin/followers/approve", approveFollowController(mp, this));
|
|
320
|
+
router.post("/admin/followers/reject", rejectFollowController(mp, this));
|
|
307
321
|
router.get("/admin/following", followingController(mp));
|
|
308
322
|
router.get("/admin/activities", activitiesController(mp));
|
|
309
323
|
router.get("/admin/featured", featuredGetController(mp));
|
|
@@ -493,7 +507,7 @@ export default class ActivityPubEndpoint {
|
|
|
493
507
|
let replyToActor = null;
|
|
494
508
|
if (properties["in-reply-to"]) {
|
|
495
509
|
try {
|
|
496
|
-
const remoteObject = await ctx
|
|
510
|
+
const remoteObject = await lookupWithSecurity(ctx,
|
|
497
511
|
new URL(properties["in-reply-to"]),
|
|
498
512
|
);
|
|
499
513
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
|
@@ -525,7 +539,7 @@ export default class ActivityPubEndpoint {
|
|
|
525
539
|
|
|
526
540
|
for (const { handle } of mentionHandles) {
|
|
527
541
|
try {
|
|
528
|
-
const mentionedActor = await ctx
|
|
542
|
+
const mentionedActor = await lookupWithSecurity(ctx,
|
|
529
543
|
new URL(`acct:${handle}`),
|
|
530
544
|
);
|
|
531
545
|
if (mentionedActor?.id) {
|
|
@@ -701,7 +715,7 @@ export default class ActivityPubEndpoint {
|
|
|
701
715
|
const documentLoader = await ctx.getDocumentLoader({
|
|
702
716
|
identifier: handle,
|
|
703
717
|
});
|
|
704
|
-
const remoteActor = await ctx
|
|
718
|
+
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
|
|
705
719
|
documentLoader,
|
|
706
720
|
});
|
|
707
721
|
if (!remoteActor) {
|
|
@@ -802,7 +816,7 @@ export default class ActivityPubEndpoint {
|
|
|
802
816
|
const documentLoader = await ctx.getDocumentLoader({
|
|
803
817
|
identifier: handle,
|
|
804
818
|
});
|
|
805
|
-
const remoteActor = await ctx
|
|
819
|
+
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
|
|
806
820
|
documentLoader,
|
|
807
821
|
});
|
|
808
822
|
if (!remoteActor) {
|
|
@@ -1115,6 +1129,14 @@ export default class ActivityPubEndpoint {
|
|
|
1115
1129
|
Indiekit.addCollection("ap_explore_tabs");
|
|
1116
1130
|
// Reports collection
|
|
1117
1131
|
Indiekit.addCollection("ap_reports");
|
|
1132
|
+
// Pending follow requests (manual approval)
|
|
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");
|
|
1118
1140
|
|
|
1119
1141
|
// Store collection references (posts resolved lazily)
|
|
1120
1142
|
const indiekitCollections = Indiekit.collections;
|
|
@@ -1140,6 +1162,14 @@ export default class ActivityPubEndpoint {
|
|
|
1140
1162
|
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
|
|
1141
1163
|
// Reports collection
|
|
1142
1164
|
ap_reports: indiekitCollections.get("ap_reports"),
|
|
1165
|
+
// Pending follow requests (manual approval)
|
|
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"),
|
|
1143
1173
|
get posts() {
|
|
1144
1174
|
return indiekitCollections.get("posts");
|
|
1145
1175
|
},
|
|
@@ -1331,6 +1361,36 @@ export default class ActivityPubEndpoint {
|
|
|
1331
1361
|
{ reportedUrls: 1 },
|
|
1332
1362
|
{ background: true },
|
|
1333
1363
|
);
|
|
1364
|
+
// Pending follow requests — unique on actorUrl
|
|
1365
|
+
this._collections.ap_pending_follows.createIndex(
|
|
1366
|
+
{ actorUrl: 1 },
|
|
1367
|
+
{ unique: true, background: true },
|
|
1368
|
+
);
|
|
1369
|
+
this._collections.ap_pending_follows.createIndex(
|
|
1370
|
+
{ requestedAt: -1 },
|
|
1371
|
+
{ background: true },
|
|
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
|
+
);
|
|
1334
1394
|
} catch {
|
|
1335
1395
|
// Index creation failed — collections not yet available.
|
|
1336
1396
|
// Indexes already exist from previous startups; non-fatal.
|
|
@@ -1375,7 +1435,7 @@ export default class ActivityPubEndpoint {
|
|
|
1375
1435
|
const documentLoader = await ctx.getDocumentLoader({
|
|
1376
1436
|
identifier: handle,
|
|
1377
1437
|
});
|
|
1378
|
-
const actor = await ctx
|
|
1438
|
+
const actor = await lookupWithSecurity(ctx,new URL(actorUrl), {
|
|
1379
1439
|
documentLoader,
|
|
1380
1440
|
});
|
|
1381
1441
|
if (!actor) return "";
|
|
@@ -1426,6 +1486,34 @@ export default class ActivityPubEndpoint {
|
|
|
1426
1486
|
if (this.options.timelineRetention > 0) {
|
|
1427
1487
|
scheduleCleanup(this._collections, this.options.timelineRetention);
|
|
1428
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
|
+
);
|
|
1429
1517
|
}
|
|
1430
1518
|
|
|
1431
1519
|
/**
|
package/lib/batch-refollow.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lookupWithSecurity } from "./lookup-helpers.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Batch re-follow processor for imported accounts.
|
|
3
5
|
*
|
|
@@ -232,7 +234,7 @@ async function processOneFollow(options, entry) {
|
|
|
232
234
|
const documentLoader = await ctx.getDocumentLoader({
|
|
233
235
|
identifier: handle,
|
|
234
236
|
});
|
|
235
|
-
const remoteActor = await ctx
|
|
237
|
+
const remoteActor = await lookupWithSecurity(ctx,entry.actorUrl, {
|
|
236
238
|
documentLoader,
|
|
237
239
|
});
|
|
238
240
|
if (!remoteActor) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { getToken, validateToken } from "../csrf.js";
|
|
6
6
|
import { sanitizeContent } from "../timeline-store.js";
|
|
7
|
+
import { lookupWithSecurity } from "../lookup-helpers.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Fetch syndication targets from the Micropub config endpoint.
|
|
@@ -79,7 +80,7 @@ export function composeController(mountPath, plugin) {
|
|
|
79
80
|
const documentLoader = await ctx.getDocumentLoader({
|
|
80
81
|
identifier: handle,
|
|
81
82
|
});
|
|
82
|
-
const remoteObject = await ctx
|
|
83
|
+
const remoteObject = await lookupWithSecurity(ctx,new URL(replyTo), {
|
|
83
84
|
documentLoader,
|
|
84
85
|
});
|
|
85
86
|
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* the relationship between local content and the fediverse.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import Redis from "ioredis";
|
|
6
7
|
import { getToken, validateToken } from "../csrf.js";
|
|
7
8
|
import { jf2ToActivityStreams } from "../jf2-to-as2.js";
|
|
9
|
+
import { lookupWithSecurity } from "../lookup-helpers.js";
|
|
8
10
|
|
|
9
11
|
const PAGE_SIZE = 20;
|
|
10
12
|
|
|
@@ -37,10 +39,12 @@ export function federationMgmtController(mountPath, plugin) {
|
|
|
37
39
|
const { application } = request.app.locals;
|
|
38
40
|
const collections = application?.collections;
|
|
39
41
|
|
|
42
|
+
const redisUrl = plugin.options.redisUrl || "";
|
|
43
|
+
|
|
40
44
|
// Parallel: collection stats + posts + recent activities
|
|
41
45
|
const [collectionStats, postsResult, recentActivities] =
|
|
42
46
|
await Promise.all([
|
|
43
|
-
getCollectionStats(collections),
|
|
47
|
+
getCollectionStats(collections, { redisUrl }),
|
|
44
48
|
getPaginatedPosts(collections, request.query.page),
|
|
45
49
|
getRecentActivities(collections),
|
|
46
50
|
]);
|
|
@@ -219,7 +223,7 @@ export function lookupObjectController(mountPath, plugin) {
|
|
|
219
223
|
identifier: handle,
|
|
220
224
|
});
|
|
221
225
|
|
|
222
|
-
const object = await ctx
|
|
226
|
+
const object = await lookupWithSecurity(ctx,query, { documentLoader });
|
|
223
227
|
|
|
224
228
|
if (!object) {
|
|
225
229
|
return response
|
|
@@ -239,11 +243,16 @@ export function lookupObjectController(mountPath, plugin) {
|
|
|
239
243
|
|
|
240
244
|
// --- Helpers ---
|
|
241
245
|
|
|
242
|
-
async function getCollectionStats(collections) {
|
|
246
|
+
async function getCollectionStats(collections, { redisUrl = "" } = {}) {
|
|
243
247
|
if (!collections) return [];
|
|
244
248
|
|
|
245
249
|
const stats = await Promise.all(
|
|
246
250
|
AP_COLLECTIONS.map(async (name) => {
|
|
251
|
+
// When Redis handles KV, count fedify::* keys from Redis instead
|
|
252
|
+
if (name === "ap_kv" && redisUrl) {
|
|
253
|
+
const count = await countRedisKvKeys(redisUrl);
|
|
254
|
+
return { name: "ap_kv (redis)", count };
|
|
255
|
+
}
|
|
247
256
|
const col = collections.get(name);
|
|
248
257
|
const count = col ? await col.countDocuments() : 0;
|
|
249
258
|
return { name, count };
|
|
@@ -253,6 +262,36 @@ async function getCollectionStats(collections) {
|
|
|
253
262
|
return stats;
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Count Fedify KV keys in Redis (prefix: "fedify::").
|
|
267
|
+
* Uses SCAN to avoid blocking on large key spaces.
|
|
268
|
+
*/
|
|
269
|
+
async function countRedisKvKeys(redisUrl) {
|
|
270
|
+
let client;
|
|
271
|
+
try {
|
|
272
|
+
client = new Redis(redisUrl, { lazyConnect: true, connectTimeout: 3000 });
|
|
273
|
+
await client.connect();
|
|
274
|
+
let count = 0;
|
|
275
|
+
let cursor = "0";
|
|
276
|
+
do {
|
|
277
|
+
const [nextCursor, keys] = await client.scan(
|
|
278
|
+
cursor,
|
|
279
|
+
"MATCH",
|
|
280
|
+
"fedify::*",
|
|
281
|
+
"COUNT",
|
|
282
|
+
500,
|
|
283
|
+
);
|
|
284
|
+
cursor = nextCursor;
|
|
285
|
+
count += keys.length;
|
|
286
|
+
} while (cursor !== "0");
|
|
287
|
+
return count;
|
|
288
|
+
} catch {
|
|
289
|
+
return 0;
|
|
290
|
+
} finally {
|
|
291
|
+
client?.disconnect();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
256
295
|
async function getPaginatedPosts(collections, pageParam) {
|
|
257
296
|
const postsCol = collections?.get("posts");
|
|
258
297
|
if (!postsCol) return { posts: [], cursor: null };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow request controllers — approve and reject pending follow requests
|
|
3
|
+
* when manual follow approval is enabled.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { validateToken } from "../csrf.js";
|
|
7
|
+
import { lookupWithSecurity } from "../lookup-helpers.js";
|
|
8
|
+
import { logActivity } from "../activity-log.js";
|
|
9
|
+
import { addNotification } from "../storage/notifications.js";
|
|
10
|
+
import { extractActorInfo } from "../timeline-store.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /admin/followers/approve — Accept a pending follow request.
|
|
14
|
+
*/
|
|
15
|
+
export function approveFollowController(mountPath, plugin) {
|
|
16
|
+
return async (request, response, next) => {
|
|
17
|
+
try {
|
|
18
|
+
if (!validateToken(request)) {
|
|
19
|
+
return response.status(403).json({
|
|
20
|
+
success: false,
|
|
21
|
+
error: "Invalid CSRF token",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { actorUrl } = request.body;
|
|
26
|
+
|
|
27
|
+
if (!actorUrl) {
|
|
28
|
+
return response.status(400).json({
|
|
29
|
+
success: false,
|
|
30
|
+
error: "Missing actor URL",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { application } = request.app.locals;
|
|
35
|
+
const pendingCol = application?.collections?.get("ap_pending_follows");
|
|
36
|
+
const followersCol = application?.collections?.get("ap_followers");
|
|
37
|
+
|
|
38
|
+
if (!pendingCol || !followersCol) {
|
|
39
|
+
return response.status(503).json({
|
|
40
|
+
success: false,
|
|
41
|
+
error: "Collections not available",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find the pending request
|
|
46
|
+
const pending = await pendingCol.findOne({ actorUrl });
|
|
47
|
+
if (!pending) {
|
|
48
|
+
return response.status(404).json({
|
|
49
|
+
success: false,
|
|
50
|
+
error: "No pending follow request from this actor",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Move to ap_followers
|
|
55
|
+
await followersCol.updateOne(
|
|
56
|
+
{ actorUrl },
|
|
57
|
+
{
|
|
58
|
+
$set: {
|
|
59
|
+
actorUrl: pending.actorUrl,
|
|
60
|
+
handle: pending.handle || "",
|
|
61
|
+
name: pending.name || "",
|
|
62
|
+
avatar: pending.avatar || "",
|
|
63
|
+
inbox: pending.inbox || "",
|
|
64
|
+
sharedInbox: pending.sharedInbox || "",
|
|
65
|
+
followedAt: new Date().toISOString(),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{ upsert: true },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Remove from pending
|
|
72
|
+
await pendingCol.deleteOne({ actorUrl });
|
|
73
|
+
|
|
74
|
+
// Send Accept(Follow) via federation
|
|
75
|
+
if (plugin._federation) {
|
|
76
|
+
try {
|
|
77
|
+
const { Accept, Follow } = await import("@fedify/fedify/vocab");
|
|
78
|
+
const handle = plugin.options.actor.handle;
|
|
79
|
+
const ctx = plugin._federation.createContext(
|
|
80
|
+
new URL(plugin._publicationUrl),
|
|
81
|
+
{ handle, publicationUrl: plugin._publicationUrl },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const documentLoader = await ctx.getDocumentLoader({
|
|
85
|
+
identifier: handle,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Resolve the remote actor for delivery
|
|
89
|
+
const remoteActor = await lookupWithSecurity(ctx, new URL(actorUrl), {
|
|
90
|
+
documentLoader,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (remoteActor) {
|
|
94
|
+
// Reconstruct the Follow using stored activity ID
|
|
95
|
+
const followObj = new Follow({
|
|
96
|
+
id: pending.followActivityId
|
|
97
|
+
? new URL(pending.followActivityId)
|
|
98
|
+
: undefined,
|
|
99
|
+
actor: new URL(actorUrl),
|
|
100
|
+
object: ctx.getActorUri(handle),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await ctx.sendActivity(
|
|
104
|
+
{ identifier: handle },
|
|
105
|
+
remoteActor,
|
|
106
|
+
new Accept({
|
|
107
|
+
actor: ctx.getActorUri(handle),
|
|
108
|
+
object: followObj,
|
|
109
|
+
}),
|
|
110
|
+
{ orderingKey: actorUrl },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const activitiesCol = application?.collections?.get("ap_activities");
|
|
115
|
+
if (activitiesCol) {
|
|
116
|
+
await logActivity(activitiesCol, {
|
|
117
|
+
direction: "outbound",
|
|
118
|
+
type: "Accept(Follow)",
|
|
119
|
+
actorUrl: plugin._publicationUrl,
|
|
120
|
+
objectUrl: actorUrl,
|
|
121
|
+
actorName: pending.name || actorUrl,
|
|
122
|
+
summary: `Approved follow request from ${pending.name || actorUrl}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[ActivityPub] Could not send Accept to ${actorUrl}: ${error.message}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.info(
|
|
133
|
+
`[ActivityPub] Approved follow request from ${pending.name || actorUrl}`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Redirect back to followers page
|
|
137
|
+
return response.redirect(`${mountPath}/admin/followers`);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
next(error);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* POST /admin/followers/reject — Reject a pending follow request.
|
|
146
|
+
*/
|
|
147
|
+
export function rejectFollowController(mountPath, plugin) {
|
|
148
|
+
return async (request, response, next) => {
|
|
149
|
+
try {
|
|
150
|
+
if (!validateToken(request)) {
|
|
151
|
+
return response.status(403).json({
|
|
152
|
+
success: false,
|
|
153
|
+
error: "Invalid CSRF token",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { actorUrl } = request.body;
|
|
158
|
+
|
|
159
|
+
if (!actorUrl) {
|
|
160
|
+
return response.status(400).json({
|
|
161
|
+
success: false,
|
|
162
|
+
error: "Missing actor URL",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { application } = request.app.locals;
|
|
167
|
+
const pendingCol = application?.collections?.get("ap_pending_follows");
|
|
168
|
+
|
|
169
|
+
if (!pendingCol) {
|
|
170
|
+
return response.status(503).json({
|
|
171
|
+
success: false,
|
|
172
|
+
error: "Collections not available",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Find the pending request
|
|
177
|
+
const pending = await pendingCol.findOne({ actorUrl });
|
|
178
|
+
if (!pending) {
|
|
179
|
+
return response.status(404).json({
|
|
180
|
+
success: false,
|
|
181
|
+
error: "No pending follow request from this actor",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Remove from pending
|
|
186
|
+
await pendingCol.deleteOne({ actorUrl });
|
|
187
|
+
|
|
188
|
+
// Send Reject(Follow) via federation
|
|
189
|
+
if (plugin._federation) {
|
|
190
|
+
try {
|
|
191
|
+
const { Reject, Follow } = await import("@fedify/fedify/vocab");
|
|
192
|
+
const handle = plugin.options.actor.handle;
|
|
193
|
+
const ctx = plugin._federation.createContext(
|
|
194
|
+
new URL(plugin._publicationUrl),
|
|
195
|
+
{ handle, publicationUrl: plugin._publicationUrl },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const documentLoader = await ctx.getDocumentLoader({
|
|
199
|
+
identifier: handle,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const remoteActor = await lookupWithSecurity(ctx, new URL(actorUrl), {
|
|
203
|
+
documentLoader,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (remoteActor) {
|
|
207
|
+
const followObj = new Follow({
|
|
208
|
+
id: pending.followActivityId
|
|
209
|
+
? new URL(pending.followActivityId)
|
|
210
|
+
: undefined,
|
|
211
|
+
actor: new URL(actorUrl),
|
|
212
|
+
object: ctx.getActorUri(handle),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await ctx.sendActivity(
|
|
216
|
+
{ identifier: handle },
|
|
217
|
+
remoteActor,
|
|
218
|
+
new Reject({
|
|
219
|
+
actor: ctx.getActorUri(handle),
|
|
220
|
+
object: followObj,
|
|
221
|
+
}),
|
|
222
|
+
{ orderingKey: actorUrl },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const activitiesCol = application?.collections?.get("ap_activities");
|
|
227
|
+
if (activitiesCol) {
|
|
228
|
+
await logActivity(activitiesCol, {
|
|
229
|
+
direction: "outbound",
|
|
230
|
+
type: "Reject(Follow)",
|
|
231
|
+
actorUrl: plugin._publicationUrl,
|
|
232
|
+
objectUrl: actorUrl,
|
|
233
|
+
actorName: pending.name || actorUrl,
|
|
234
|
+
summary: `Rejected follow request from ${pending.name || actorUrl}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn(
|
|
239
|
+
`[ActivityPub] Could not send Reject to ${actorUrl}: ${error.message}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.info(
|
|
245
|
+
`[ActivityPub] Rejected follow request from ${pending.name || actorUrl}`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return response.redirect(`${mountPath}/admin/followers`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
next(error);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Followers list controller — paginated list of accounts following this actor
|
|
2
|
+
* Followers list controller — paginated list of accounts following this actor,
|
|
3
|
+
* with pending follow requests tab when manual approval is enabled.
|
|
3
4
|
*/
|
|
5
|
+
import { getToken } from "../csrf.js";
|
|
6
|
+
|
|
4
7
|
const PAGE_SIZE = 20;
|
|
5
8
|
|
|
6
9
|
export function followersController(mountPath) {
|
|
@@ -8,6 +11,9 @@ export function followersController(mountPath) {
|
|
|
8
11
|
try {
|
|
9
12
|
const { application } = request.app.locals;
|
|
10
13
|
const collection = application?.collections?.get("ap_followers");
|
|
14
|
+
const pendingCol = application?.collections?.get("ap_pending_follows");
|
|
15
|
+
|
|
16
|
+
const tab = request.query.tab || "followers";
|
|
11
17
|
|
|
12
18
|
if (!collection) {
|
|
13
19
|
return response.render("activitypub-followers", {
|
|
@@ -15,11 +21,50 @@ export function followersController(mountPath) {
|
|
|
15
21
|
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
|
16
22
|
followers: [],
|
|
17
23
|
followerCount: 0,
|
|
24
|
+
pendingFollows: [],
|
|
25
|
+
pendingCount: 0,
|
|
26
|
+
tab,
|
|
18
27
|
mountPath,
|
|
28
|
+
csrfToken: getToken(request),
|
|
19
29
|
});
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
|
|
33
|
+
|
|
34
|
+
// Count pending follow requests
|
|
35
|
+
const pendingCount = pendingCol
|
|
36
|
+
? await pendingCol.countDocuments()
|
|
37
|
+
: 0;
|
|
38
|
+
|
|
39
|
+
if (tab === "pending") {
|
|
40
|
+
// Show pending follow requests
|
|
41
|
+
const totalPages = Math.ceil(pendingCount / PAGE_SIZE);
|
|
42
|
+
const pendingFollows = pendingCol
|
|
43
|
+
? await pendingCol
|
|
44
|
+
.find()
|
|
45
|
+
.sort({ requestedAt: -1 })
|
|
46
|
+
.skip((page - 1) * PAGE_SIZE)
|
|
47
|
+
.limit(PAGE_SIZE)
|
|
48
|
+
.toArray()
|
|
49
|
+
: [];
|
|
50
|
+
|
|
51
|
+
const cursor = buildCursor(page, totalPages, mountPath + "/admin/followers?tab=pending");
|
|
52
|
+
|
|
53
|
+
return response.render("activitypub-followers", {
|
|
54
|
+
title: response.locals.__("activitypub.followers"),
|
|
55
|
+
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
|
56
|
+
followers: [],
|
|
57
|
+
followerCount: await collection.countDocuments(),
|
|
58
|
+
pendingFollows,
|
|
59
|
+
pendingCount,
|
|
60
|
+
tab,
|
|
61
|
+
mountPath,
|
|
62
|
+
cursor,
|
|
63
|
+
csrfToken: getToken(request),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Show accepted followers (default)
|
|
23
68
|
const totalCount = await collection.countDocuments();
|
|
24
69
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
25
70
|
|
|
@@ -37,8 +82,12 @@ export function followersController(mountPath) {
|
|
|
37
82
|
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
|
38
83
|
followers,
|
|
39
84
|
followerCount: totalCount,
|
|
85
|
+
pendingFollows: [],
|
|
86
|
+
pendingCount,
|
|
87
|
+
tab,
|
|
40
88
|
mountPath,
|
|
41
89
|
cursor,
|
|
90
|
+
csrfToken: getToken(request),
|
|
42
91
|
});
|
|
43
92
|
} catch (error) {
|
|
44
93
|
next(error);
|
|
@@ -49,12 +98,14 @@ export function followersController(mountPath) {
|
|
|
49
98
|
function buildCursor(page, totalPages, basePath) {
|
|
50
99
|
if (totalPages <= 1) return null;
|
|
51
100
|
|
|
101
|
+
const separator = basePath.includes("?") ? "&" : "?";
|
|
102
|
+
|
|
52
103
|
return {
|
|
53
104
|
previous: page > 1
|
|
54
|
-
? { href: `${basePath}
|
|
105
|
+
? { href: `${basePath}${separator}page=${page - 1}` }
|
|
55
106
|
: undefined,
|
|
56
107
|
next: page < totalPages
|
|
57
|
-
? { href: `${basePath}
|
|
108
|
+
? { href: `${basePath}${separator}page=${page + 1}` }
|
|
58
109
|
: undefined,
|
|
59
110
|
};
|
|
60
111
|
}
|