@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 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.lookupObject(
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.lookupObject(
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.lookupObject(actorUrl, {
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.lookupObject(actorUrl, {
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.lookupObject(new URL(actorUrl), {
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
  /**
@@ -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.lookupObject(entry.actorUrl, {
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.lookupObject(new URL(replyTo), {
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.lookupObject(query, { documentLoader });
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}?page=${page - 1}` }
105
+ ? { href: `${basePath}${separator}page=${page - 1}` }
55
106
  : undefined,
56
107
  next: page < totalPages
57
- ? { href: `${basePath}?page=${page + 1}` }
108
+ ? { href: `${basePath}${separator}page=${page + 1}` }
58
109
  : undefined,
59
110
  };
60
111
  }