@rmdes/indiekit-endpoint-activitypub 2.12.1 → 2.13.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";
@@ -39,6 +40,10 @@ import {
39
40
  filterModeController,
40
41
  } from "./lib/controllers/moderation.js";
41
42
  import { followersController } from "./lib/controllers/followers.js";
43
+ import {
44
+ approveFollowController,
45
+ rejectFollowController,
46
+ } from "./lib/controllers/follow-requests.js";
42
47
  import { followingController } from "./lib/controllers/following.js";
43
48
  import { activitiesController } from "./lib/controllers/activities.js";
44
49
  import {
@@ -304,6 +309,8 @@ export default class ActivityPubEndpoint {
304
309
  router.post("/admin/reader/block", blockController(mp, this));
305
310
  router.post("/admin/reader/unblock", unblockController(mp, this));
306
311
  router.get("/admin/followers", followersController(mp));
312
+ router.post("/admin/followers/approve", approveFollowController(mp, this));
313
+ router.post("/admin/followers/reject", rejectFollowController(mp, this));
307
314
  router.get("/admin/following", followingController(mp));
308
315
  router.get("/admin/activities", activitiesController(mp));
309
316
  router.get("/admin/featured", featuredGetController(mp));
@@ -493,7 +500,7 @@ export default class ActivityPubEndpoint {
493
500
  let replyToActor = null;
494
501
  if (properties["in-reply-to"]) {
495
502
  try {
496
- const remoteObject = await ctx.lookupObject(
503
+ const remoteObject = await lookupWithSecurity(ctx,
497
504
  new URL(properties["in-reply-to"]),
498
505
  );
499
506
  if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
@@ -525,7 +532,7 @@ export default class ActivityPubEndpoint {
525
532
 
526
533
  for (const { handle } of mentionHandles) {
527
534
  try {
528
- const mentionedActor = await ctx.lookupObject(
535
+ const mentionedActor = await lookupWithSecurity(ctx,
529
536
  new URL(`acct:${handle}`),
530
537
  );
531
538
  if (mentionedActor?.id) {
@@ -701,7 +708,7 @@ export default class ActivityPubEndpoint {
701
708
  const documentLoader = await ctx.getDocumentLoader({
702
709
  identifier: handle,
703
710
  });
704
- const remoteActor = await ctx.lookupObject(actorUrl, {
711
+ const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
705
712
  documentLoader,
706
713
  });
707
714
  if (!remoteActor) {
@@ -802,7 +809,7 @@ export default class ActivityPubEndpoint {
802
809
  const documentLoader = await ctx.getDocumentLoader({
803
810
  identifier: handle,
804
811
  });
805
- const remoteActor = await ctx.lookupObject(actorUrl, {
812
+ const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
806
813
  documentLoader,
807
814
  });
808
815
  if (!remoteActor) {
@@ -1115,6 +1122,8 @@ export default class ActivityPubEndpoint {
1115
1122
  Indiekit.addCollection("ap_explore_tabs");
1116
1123
  // Reports collection
1117
1124
  Indiekit.addCollection("ap_reports");
1125
+ // Pending follow requests (manual approval)
1126
+ Indiekit.addCollection("ap_pending_follows");
1118
1127
 
1119
1128
  // Store collection references (posts resolved lazily)
1120
1129
  const indiekitCollections = Indiekit.collections;
@@ -1140,6 +1149,8 @@ export default class ActivityPubEndpoint {
1140
1149
  ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
1141
1150
  // Reports collection
1142
1151
  ap_reports: indiekitCollections.get("ap_reports"),
1152
+ // Pending follow requests (manual approval)
1153
+ ap_pending_follows: indiekitCollections.get("ap_pending_follows"),
1143
1154
  get posts() {
1144
1155
  return indiekitCollections.get("posts");
1145
1156
  },
@@ -1331,6 +1342,15 @@ export default class ActivityPubEndpoint {
1331
1342
  { reportedUrls: 1 },
1332
1343
  { background: true },
1333
1344
  );
1345
+ // Pending follow requests — unique on actorUrl
1346
+ this._collections.ap_pending_follows.createIndex(
1347
+ { actorUrl: 1 },
1348
+ { unique: true, background: true },
1349
+ );
1350
+ this._collections.ap_pending_follows.createIndex(
1351
+ { requestedAt: -1 },
1352
+ { background: true },
1353
+ );
1334
1354
  } catch {
1335
1355
  // Index creation failed — collections not yet available.
1336
1356
  // Indexes already exist from previous startups; non-fatal.
@@ -1375,7 +1395,7 @@ export default class ActivityPubEndpoint {
1375
1395
  const documentLoader = await ctx.getDocumentLoader({
1376
1396
  identifier: handle,
1377
1397
  });
1378
- const actor = await ctx.lookupObject(new URL(actorUrl), {
1398
+ const actor = await lookupWithSecurity(ctx,new URL(actorUrl), {
1379
1399
  documentLoader,
1380
1400
  });
1381
1401
  if (!actor) return "";
@@ -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
  }
@@ -198,6 +198,7 @@ export function unboostController(mountPath, plugin) {
198
198
  // Send to followers
199
199
  await ctx.sendActivity({ identifier: handle }, "followers", undo, {
200
200
  preferSharedInbox: true,
201
+ syncCollection: true,
201
202
  orderingKey: url,
202
203
  });
203
204
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { getToken, validateToken } from "../csrf.js";
7
7
  import { sanitizeContent } from "../timeline-store.js";
8
+ import { lookupWithSecurity } from "../lookup-helpers.js";
8
9
  import {
9
10
  getMessages,
10
11
  getConversationPartners,
@@ -180,11 +181,11 @@ export function submitMessageController(mountPath, plugin) {
180
181
  try {
181
182
  const recipientInput = to.trim();
182
183
  if (recipientInput.startsWith("http")) {
183
- recipient = await ctx.lookupObject(recipientInput, { documentLoader });
184
+ recipient = await lookupWithSecurity(ctx,recipientInput, { documentLoader });
184
185
  } else {
185
186
  // Handle @user@domain format
186
187
  const handle = recipientInput.replace(/^@/, "");
187
- recipient = await ctx.lookupObject(handle, { documentLoader });
188
+ recipient = await lookupWithSecurity(ctx,handle, { documentLoader });
188
189
  }
189
190
  } catch {
190
191
  recipient = null;
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { validateToken, getToken } from "../csrf.js";
6
+ import { lookupWithSecurity } from "../lookup-helpers.js";
6
7
  import {
7
8
  addMuted,
8
9
  removeMuted,
@@ -157,7 +158,7 @@ export function blockController(mountPath, plugin) {
157
158
  const documentLoader = await ctx.getDocumentLoader({
158
159
  identifier: handle,
159
160
  });
160
- const remoteActor = await ctx.lookupObject(new URL(url), {
161
+ const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
161
162
  documentLoader,
162
163
  });
163
164
 
@@ -236,7 +237,7 @@ export function unblockController(mountPath, plugin) {
236
237
  const documentLoader = await ctx.getDocumentLoader({
237
238
  identifier: handle,
238
239
  });
239
- const remoteActor = await ctx.lookupObject(new URL(url), {
240
+ const remoteActor = await lookupWithSecurity(ctx,new URL(url), {
240
241
  documentLoader,
241
242
  });
242
243
 
@@ -4,6 +4,7 @@ import { getToken } from "../csrf.js";
4
4
  import { extractObjectData, extractActorInfo } from "../timeline-store.js";
5
5
  import { getCached, setCache } from "../lookup-cache.js";
6
6
  import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
7
+ import { lookupWithSecurity } from "../lookup-helpers.js";
7
8
 
8
9
  // Load parent posts (inReplyTo chain) up to maxDepth levels
9
10
  async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
@@ -28,7 +29,7 @@ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxD
28
29
 
29
30
  if (!object) {
30
31
  try {
31
- object = await ctx.lookupObject(new URL(currentUrl), {
32
+ object = await lookupWithSecurity(ctx,new URL(currentUrl), {
32
33
  documentLoader,
33
34
  });
34
35
  if (object) {
@@ -180,7 +181,7 @@ export function postDetailController(mountPath, plugin) {
180
181
  object = cached;
181
182
  } else {
182
183
  try {
183
- object = await ctx.lookupObject(new URL(objectUrl), {
184
+ object = await lookupWithSecurity(ctx,new URL(objectUrl), {
184
185
  documentLoader,
185
186
  });
186
187
  if (object) {
@@ -326,7 +327,7 @@ export function postDetailController(mountPath, plugin) {
326
327
  );
327
328
  const qLoader = await qCtx.getDocumentLoader({ identifier: handle });
328
329
 
329
- const quoteObject = await qCtx.lookupObject(new URL(timelineItem.quoteUrl), {
330
+ const quoteObject = await lookupWithSecurity(qCtx,new URL(timelineItem.quoteUrl), {
330
331
  documentLoader: qLoader,
331
332
  });
332
333
 
@@ -336,7 +337,7 @@ export function postDetailController(mountPath, plugin) {
336
337
  // If author photo is empty, try fetching the actor directly
337
338
  if (!quoteData.author.photo && quoteData.author.url) {
338
339
  try {
339
- const actor = await qCtx.lookupObject(new URL(quoteData.author.url), { documentLoader: qLoader });
340
+ const actor = await lookupWithSecurity(qCtx,new URL(quoteData.author.url), { documentLoader: qLoader });
340
341
  if (actor) {
341
342
  const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
342
343
  if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;
@@ -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
  * GET /admin/reader/profile — Show remote actor profile.
@@ -43,7 +44,7 @@ export function remoteProfileController(mountPath, plugin) {
43
44
  let actor;
44
45
 
45
46
  try {
46
- actor = await ctx.lookupObject(new URL(actorUrl), { documentLoader });
47
+ actor = await lookupWithSecurity(ctx,new URL(actorUrl), { documentLoader });
47
48
  } catch {
48
49
  return response.status(404).render("error", {
49
50
  title: "Error",
@@ -2,6 +2,7 @@
2
2
  * Resolve controller — accepts any fediverse URL or handle, resolves it
3
3
  * via lookupObject(), and redirects to the appropriate internal view.
4
4
  */
5
+ import { lookupWithSecurity } from "../lookup-helpers.js";
5
6
  import {
6
7
  Article,
7
8
  Note,
@@ -59,7 +60,7 @@ export function resolveController(mountPath, plugin) {
59
60
  let object;
60
61
 
61
62
  try {
62
- object = await ctx.lookupObject(lookupInput, { documentLoader });
63
+ object = await lookupWithSecurity(ctx,lookupInput, { documentLoader });
63
64
  } catch (error) {
64
65
  console.warn(
65
66
  `[resolve] lookupObject failed for "${query}":`,
@@ -99,55 +99,100 @@ export function registerInboxListeners(inboxChain, options) {
99
99
  followerActor.preferredUsername?.toString() ||
100
100
  followerUrl;
101
101
 
102
- await collections.ap_followers.updateOne(
103
- { actorUrl: followerUrl },
104
- {
105
- $set: {
106
- actorUrl: followerUrl,
107
- handle: followerActor.preferredUsername?.toString() || "",
108
- name: followerName,
109
- avatar: followerActor.icon
110
- ? (await followerActor.icon)?.url?.href || ""
111
- : "",
112
- inbox: followerActor.inbox?.id?.href || "",
113
- sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
114
- followedAt: new Date().toISOString(),
102
+ // Build common follower data
103
+ const followerData = {
104
+ actorUrl: followerUrl,
105
+ handle: followerActor.preferredUsername?.toString() || "",
106
+ name: followerName,
107
+ avatar: followerActor.icon
108
+ ? (await followerActor.icon)?.url?.href || ""
109
+ : "",
110
+ inbox: followerActor.inbox?.id?.href || "",
111
+ sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
112
+ };
113
+
114
+ // Check if manual approval is enabled
115
+ const profile = await collections.ap_profile.findOne({});
116
+ const manualApproval = profile?.manuallyApprovesFollowers || false;
117
+
118
+ if (manualApproval && collections.ap_pending_follows) {
119
+ // Store as pending — do NOT send Accept yet
120
+ await collections.ap_pending_follows.updateOne(
121
+ { actorUrl: followerUrl },
122
+ {
123
+ $set: {
124
+ ...followerData,
125
+ followActivityId: follow.id?.href || "",
126
+ requestedAt: new Date().toISOString(),
127
+ },
115
128
  },
116
- },
117
- { upsert: true },
118
- );
129
+ { upsert: true },
130
+ );
119
131
 
120
- // Auto-accept: send Accept back
121
- await ctx.sendActivity(
122
- { identifier: handle },
123
- followerActor,
124
- new Accept({
125
- actor: ctx.getActorUri(handle),
126
- object: follow,
127
- }),
128
- { orderingKey: followerUrl },
129
- );
132
+ await logActivity(collections, storeRawActivities, {
133
+ direction: "inbound",
134
+ type: "Follow",
135
+ actorUrl: followerUrl,
136
+ actorName: followerName,
137
+ summary: `${followerName} requested to follow you`,
138
+ });
130
139
 
131
- await logActivity(collections, storeRawActivities, {
132
- direction: "inbound",
133
- type: "Follow",
134
- actorUrl: followerUrl,
135
- actorName: followerName,
136
- summary: `${followerName} followed you`,
137
- });
140
+ // Notification with type "follow_request"
141
+ const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
142
+ await addNotification(collections, {
143
+ uid: follow.id?.href || `follow_request:${followerUrl}`,
144
+ type: "follow_request",
145
+ actorUrl: followerInfo.url,
146
+ actorName: followerInfo.name,
147
+ actorPhoto: followerInfo.photo,
148
+ actorHandle: followerInfo.handle,
149
+ published: follow.published ? String(follow.published) : new Date().toISOString(),
150
+ createdAt: new Date().toISOString(),
151
+ });
152
+ } else {
153
+ // Auto-accept: store follower + send Accept back
154
+ await collections.ap_followers.updateOne(
155
+ { actorUrl: followerUrl },
156
+ {
157
+ $set: {
158
+ ...followerData,
159
+ followedAt: new Date().toISOString(),
160
+ },
161
+ },
162
+ { upsert: true },
163
+ );
138
164
 
139
- // Store notification
140
- const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
141
- await addNotification(collections, {
142
- uid: follow.id?.href || `follow:${followerUrl}`,
143
- type: "follow",
144
- actorUrl: followerInfo.url,
145
- actorName: followerInfo.name,
146
- actorPhoto: followerInfo.photo,
147
- actorHandle: followerInfo.handle,
148
- published: follow.published ? String(follow.published) : new Date().toISOString(),
149
- createdAt: new Date().toISOString(),
150
- });
165
+ await ctx.sendActivity(
166
+ { identifier: handle },
167
+ followerActor,
168
+ new Accept({
169
+ actor: ctx.getActorUri(handle),
170
+ object: follow,
171
+ }),
172
+ { orderingKey: followerUrl },
173
+ );
174
+
175
+ await logActivity(collections, storeRawActivities, {
176
+ direction: "inbound",
177
+ type: "Follow",
178
+ actorUrl: followerUrl,
179
+ actorName: followerName,
180
+ summary: `${followerName} followed you`,
181
+ });
182
+
183
+ // Store notification
184
+ const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
185
+ await addNotification(collections, {
186
+ uid: follow.id?.href || `follow:${followerUrl}`,
187
+ type: "follow",
188
+ actorUrl: followerInfo.url,
189
+ actorName: followerInfo.name,
190
+ actorPhoto: followerInfo.photo,
191
+ actorHandle: followerInfo.handle,
192
+ published: follow.published ? String(follow.published) : new Date().toISOString(),
193
+ createdAt: new Date().toISOString(),
194
+ });
195
+ }
151
196
  })
152
197
  .on(Undo, async (ctx, undo) => {
153
198
  const actorUrl = undo.actorId?.href || "";
package/lib/jf2-to-as2.js CHANGED
@@ -536,7 +536,7 @@ function buildPlainTags(properties, publicationUrl, existing) {
536
536
  for (const cat of asArray(properties.category)) {
537
537
  tags.push({
538
538
  type: "Hashtag",
539
- name: `#${cat.replace(/\s+/g, "")}`,
539
+ name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
540
540
  href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
541
541
  });
542
542
  }
@@ -558,7 +558,7 @@ function buildFedifyTags(properties, publicationUrl, postType) {
558
558
  for (const cat of asArray(properties.category)) {
559
559
  tags.push(
560
560
  new Hashtag({
561
- name: `#${cat.replace(/\s+/g, "")}`,
561
+ name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`,
562
562
  href: new URL(
563
563
  `${publicationUrl}categories/${encodeURIComponent(cat)}`,
564
564
  ),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Centralized wrapper for ctx.lookupObject() with FEP-fe34 origin-based
3
+ * security. All lookupObject calls MUST go through this helper so the
4
+ * crossOrigin policy is applied consistently.
5
+ *
6
+ * @module lookup-helpers
7
+ */
8
+
9
+ /**
10
+ * Look up a remote ActivityPub object with cross-origin security.
11
+ *
12
+ * FEP-fe34 prevents spoofed attribution attacks by verifying that a
13
+ * fetched object's `id` matches the origin of the URL used to fetch it.
14
+ * Using `crossOrigin: "ignore"` tells Fedify to silently discard objects
15
+ * whose id doesn't match the fetch origin, rather than throwing.
16
+ *
17
+ * @param {object} ctx - Fedify Context
18
+ * @param {string|URL} input - URL or handle to look up
19
+ * @param {object} [options] - Additional options passed to lookupObject
20
+ * @returns {Promise<object|null>} Resolved object or null
21
+ */
22
+ export function lookupWithSecurity(ctx, input, options = {}) {
23
+ return ctx.lookupObject(input, {
24
+ crossOrigin: "ignore",
25
+ ...options,
26
+ });
27
+ }
package/lib/og-unfurl.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { unfurl } from "unfurl.js";
7
7
  import { extractObjectData } from "./timeline-store.js";
8
+ import { lookupWithSecurity } from "./lookup-helpers.js";
8
9
 
9
10
  const USER_AGENT =
10
11
  "Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
@@ -262,7 +263,7 @@ export async function fetchAndStorePreviews(collections, uid, html) {
262
263
  */
263
264
  export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, documentLoader) {
264
265
  try {
265
- const object = await ctx.lookupObject(new URL(quoteUrl), { documentLoader });
266
+ const object = await lookupWithSecurity(ctx,new URL(quoteUrl), { documentLoader });
266
267
  if (!object) return;
267
268
 
268
269
  const quoteData = await extractObjectData(object, { documentLoader });
@@ -270,7 +271,7 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume
270
271
  // If author photo is empty, try fetching the actor directly
271
272
  if (!quoteData.author.photo && quoteData.author.url) {
272
273
  try {
273
- const actor = await ctx.lookupObject(new URL(quoteData.author.url), { documentLoader });
274
+ const actor = await lookupWithSecurity(ctx,new URL(quoteData.author.url), { documentLoader });
274
275
  if (actor) {
275
276
  const { extractActorInfo } = await import("./timeline-store.js");
276
277
  const actorInfo = await extractActorInfo(actor, { documentLoader });
@@ -10,6 +10,8 @@
10
10
  * 3. Extract author URL from post URL pattern → lookupObject
11
11
  */
12
12
 
13
+ import { lookupWithSecurity } from "./lookup-helpers.js";
14
+
13
15
  /**
14
16
  * Extract a probable author URL from a post URL using common fediverse patterns.
15
17
  *
@@ -68,7 +70,7 @@ export async function resolveAuthor(
68
70
  ) {
69
71
  // Strategy 1: Look up remote post via Fedify (signed request)
70
72
  try {
71
- const remoteObject = await ctx.lookupObject(new URL(postUrl), {
73
+ const remoteObject = await lookupWithSecurity(ctx,new URL(postUrl), {
72
74
  documentLoader,
73
75
  });
74
76
  if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
@@ -112,7 +114,7 @@ export async function resolveAuthor(
112
114
 
113
115
  if (authorUrl) {
114
116
  try {
115
- const actor = await ctx.lookupObject(new URL(authorUrl), {
117
+ const actor = await lookupWithSecurity(ctx,new URL(authorUrl), {
116
118
  documentLoader,
117
119
  });
118
120
  if (actor) {
@@ -134,7 +136,7 @@ export async function resolveAuthor(
134
136
  const extractedUrl = extractAuthorUrl(postUrl);
135
137
  if (extractedUrl) {
136
138
  try {
137
- const actor = await ctx.lookupObject(new URL(extractedUrl), {
139
+ const actor = await lookupWithSecurity(ctx,new URL(extractedUrl), {
138
140
  documentLoader,
139
141
  });
140
142
  if (actor) {
@@ -65,8 +65,11 @@ export async function getNotifications(collections, options = {}) {
65
65
  // Type filter
66
66
  if (options.type) {
67
67
  // "reply" tab shows both replies and mentions
68
+ // "follow" tab shows both follows and follow_requests
68
69
  if (options.type === "reply") {
69
70
  query.type = { $in: ["reply", "mention"] };
71
+ } else if (options.type === "follow") {
72
+ query.type = { $in: ["follow", "follow_request"] };
70
73
  } else {
71
74
  query.type = options.type;
72
75
  }
@@ -131,6 +134,8 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals
131
134
  counts.all += count;
132
135
  if (_id === "reply" || _id === "mention") {
133
136
  counts.reply += count;
137
+ } else if (_id === "follow_request") {
138
+ counts.follow += count;
134
139
  } else if (counts[_id] !== undefined) {
135
140
  counts[_id] = count;
136
141
  }
@@ -33,6 +33,29 @@ export function sanitizeContent(html) {
33
33
  });
34
34
  }
35
35
 
36
+ /**
37
+ * Replace custom emoji :shortcode: placeholders with inline <img> tags.
38
+ * Applied AFTER sanitization — the <img> tags are controlled output from
39
+ * trusted emoji data, not user-supplied HTML.
40
+ *
41
+ * @param {string} html - Content HTML (already sanitized)
42
+ * @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji data
43
+ * @returns {string} HTML with shortcodes replaced by <img> tags
44
+ */
45
+ export function replaceCustomEmoji(html, emojis) {
46
+ if (!emojis?.length || !html) return html;
47
+ let result = html;
48
+ for (const { shortcode, url } of emojis) {
49
+ const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ const pattern = new RegExp(`:${escaped}:`, "g");
51
+ result = result.replace(
52
+ pattern,
53
+ `<img class="ap-custom-emoji" src="${url}" alt=":${shortcode}:" title=":${shortcode}:" draggable="false">`,
54
+ );
55
+ }
56
+ return result;
57
+ }
58
+
36
59
  /**
37
60
  * Extract actor information from Fedify Person/Application/Service object
38
61
  * @param {object} actor - Fedify actor object
@@ -104,7 +127,10 @@ export async function extractActorInfo(actor, options = {}) {
104
127
  // Bot detection — Service and Application actors are automated accounts
105
128
  const bot = actor instanceof Service || actor instanceof Application;
106
129
 
107
- return { name, url, photo, handle, emojis, bot };
130
+ // Replace custom emoji shortcodes in display name with <img> tags
131
+ const nameHtml = replaceCustomEmoji(name, emojis);
132
+
133
+ return { name, nameHtml, url, photo, handle, emojis, bot };
108
134
  }
109
135
 
110
136
  /**
@@ -336,6 +362,10 @@ export async function extractObjectData(object, options = {}) {
336
362
  if (shares?.totalItems != null) counts.boosts = shares.totalItems;
337
363
  } catch { /* ignore */ }
338
364
 
365
+ // Replace custom emoji :shortcode: in content with inline <img> tags.
366
+ // Applied after sanitization — these are trusted emoji from the post's tags.
367
+ content.html = replaceCustomEmoji(content.html, emojis);
368
+
339
369
  // Build base timeline item
340
370
  const item = {
341
371
  uid,
package/locales/en.json CHANGED
@@ -10,6 +10,13 @@
10
10
  "noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
11
11
  "noFollowers": "No followers yet.",
12
12
  "noFollowing": "Not following anyone yet.",
13
+ "pendingFollows": "Pending",
14
+ "noPendingFollows": "No pending follow requests.",
15
+ "approve": "Approve",
16
+ "reject": "Reject",
17
+ "followApproved": "Follow request approved.",
18
+ "followRejected": "Follow request rejected.",
19
+ "followRequest": "requested to follow you",
13
20
  "followerCount": "%d follower",
14
21
  "followerCount_plural": "%d followers",
15
22
  "followingCount": "%d following",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.12.1",
3
+ "version": "2.13.0",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -6,19 +6,67 @@
6
6
  {% from "pagination/macro.njk" import pagination with context %}
7
7
 
8
8
  {% block content %}
9
- {% if followers.length > 0 %}
10
- {% for follower in followers %}
11
- {{ card({
12
- title: follower.name or follower.handle or follower.actorUrl,
13
- url: follower.actorUrl,
14
- photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
15
- description: { text: "@" + follower.handle if follower.handle },
16
- published: follower.followedAt
17
- }) }}
18
- {% endfor %}
9
+ {# Tab navigation — only show if there are pending requests #}
10
+ {% if pendingCount > 0 %}
11
+ {% set followersBase = mountPath + "/admin/followers" %}
12
+ <nav class="ap-tabs">
13
+ <a href="{{ followersBase }}" class="ap-tab{% if tab == 'followers' %} ap-tab--active{% endif %}">
14
+ {{ __("activitypub.followers") }}
15
+ {% if followerCount %}<span class="ap-tab__count">{{ followerCount }}</span>{% endif %}
16
+ </a>
17
+ <a href="{{ followersBase }}?tab=pending" class="ap-tab{% if tab == 'pending' %} ap-tab--active{% endif %}">
18
+ {{ __("activitypub.pendingFollows") }}
19
+ <span class="ap-tab__count">{{ pendingCount }}</span>
20
+ </a>
21
+ </nav>
22
+ {% endif %}
23
+
24
+ {% if tab == "pending" %}
25
+ {# Pending follow requests #}
26
+ {% if pendingFollows.length > 0 %}
27
+ {% for pending in pendingFollows %}
28
+ <div class="ap-follow-request">
29
+ {{ card({
30
+ title: pending.name or pending.handle or pending.actorUrl,
31
+ url: pending.actorUrl,
32
+ photo: { url: pending.avatar, alt: pending.name } if pending.avatar,
33
+ description: { text: "@" + pending.handle if pending.handle }
34
+ }) }}
35
+ <div class="ap-follow-request__actions">
36
+ <form method="post" action="{{ mountPath }}/admin/followers/approve" class="ap-follow-request__form">
37
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
38
+ <input type="hidden" name="actorUrl" value="{{ pending.actorUrl }}">
39
+ <button type="submit" class="button">{{ __("activitypub.approve") }}</button>
40
+ </form>
41
+ <form method="post" action="{{ mountPath }}/admin/followers/reject" class="ap-follow-request__form">
42
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
43
+ <input type="hidden" name="actorUrl" value="{{ pending.actorUrl }}">
44
+ <button type="submit" class="button button--danger">{{ __("activitypub.reject") }}</button>
45
+ </form>
46
+ </div>
47
+ </div>
48
+ {% endfor %}
19
49
 
20
- {{ pagination(cursor) if cursor }}
50
+ {{ pagination(cursor) if cursor }}
51
+ {% else %}
52
+ {{ prose({ text: __("activitypub.noPendingFollows") }) }}
53
+ {% endif %}
21
54
  {% else %}
22
- {{ prose({ text: __("activitypub.noFollowers") }) }}
55
+ {# Accepted followers #}
56
+ {% if followers.length > 0 %}
57
+ {% for follower in followers %}
58
+ {{ card({
59
+ title: follower.name or follower.handle or follower.actorUrl,
60
+ url: follower.actorUrl,
61
+ photo: { url: follower.avatar, alt: follower.name } if follower.avatar,
62
+ description: { text: "@" + follower.handle if follower.handle },
63
+ published: follower.followedAt
64
+ }) }}
65
+ {% endfor %}
66
+
67
+ {{ pagination(cursor) if cursor }}
68
+ {% else %}
69
+ {{ prose({ text: __("activitypub.noFollowers") }) }}
70
+ {% endif %}
23
71
  {% endif %}
24
72
  {% endblock %}
@@ -15,7 +15,7 @@
15
15
  {% endif %}
16
16
  <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
17
17
  <span class="ap-notification__type-badge">
18
- {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
18
+ {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
19
19
  </span>
20
20
  </div>
21
21
 
@@ -32,6 +32,8 @@
32
32
  {{ __("activitypub.notifications.boostedPost") }}
33
33
  {% elif item.type == "follow" %}
34
34
  {{ __("activitypub.notifications.followedYou") }}
35
+ {% elif item.type == "follow_request" %}
36
+ {{ __("activitypub.followRequest") }}
35
37
  {% elif item.type == "reply" %}
36
38
  {{ __("activitypub.notifications.repliedTo") }}
37
39
  {% elif item.type == "mention" %}