@rmdes/indiekit-endpoint-activitypub 2.12.0 → 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 +25 -0
- package/index.js +25 -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 +3 -2
- 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/inbox-listeners.js +90 -45
- package/lib/jf2-to-as2.js +2 -2
- package/lib/lookup-helpers.js +27 -0
- package/lib/og-unfurl.js +3 -2
- package/lib/resolve-author.js +5 -3
- package/lib/storage/notifications.js +5 -0
- package/lib/timeline-store.js +31 -1
- package/locales/en.json +7 -0
- package/package.json +1 -1
- package/views/activitypub-federation-mgmt.njk +2 -5
- package/views/activitypub-followers.njk +60 -12
- 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";
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1398
|
+
const actor = await lookupWithSecurity(ctx,new URL(actorUrl), {
|
|
1379
1399
|
documentLoader,
|
|
1380
1400
|
});
|
|
1381
1401
|
if (!actor) return "";
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
63
|
+
object = await lookupWithSecurity(ctx,lookupInput, { documentLoader });
|
|
63
64
|
} catch (error) {
|
|
64
65
|
console.warn(
|
|
65
66
|
`[resolve] lookupObject failed for "${query}":`,
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -99,55 +99,100 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
99
99
|
followerActor.preferredUsername?.toString() ||
|
|
100
100
|
followerUrl;
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
);
|
|
129
|
+
{ upsert: true },
|
|
130
|
+
);
|
|
119
131
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
|
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 });
|
package/lib/resolve-author.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/lib/timeline-store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
{% extends "
|
|
1
|
+
{% extends "layouts/ap-reader.njk" %}
|
|
2
2
|
|
|
3
|
-
{% from "heading/macro.njk" import heading with context %}
|
|
4
3
|
{% from "card/macro.njk" import card with context %}
|
|
5
4
|
{% from "badge/macro.njk" import badge with context %}
|
|
6
5
|
{% from "prose/macro.njk" import prose with context %}
|
|
7
6
|
{% from "pagination/macro.njk" import pagination with context %}
|
|
8
7
|
|
|
9
|
-
{% block
|
|
10
|
-
<link rel="stylesheet" href="{{ mountPath }}/assets/reader.css">
|
|
11
|
-
|
|
8
|
+
{% block readercontent %}
|
|
12
9
|
<div x-data="federationMgmt()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
|
|
13
10
|
|
|
14
11
|
{# --- Collection Health --- #}
|
|
@@ -6,19 +6,67 @@
|
|
|
6
6
|
{% from "pagination/macro.njk" import pagination with context %}
|
|
7
7
|
|
|
8
8
|
{% block content %}
|
|
9
|
-
{
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
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
|
-
|
|
50
|
+
{{ pagination(cursor) if cursor }}
|
|
51
|
+
{% else %}
|
|
52
|
+
{{ prose({ text: __("activitypub.noPendingFollows") }) }}
|
|
53
|
+
{% endif %}
|
|
21
54
|
{% else %}
|
|
22
|
-
{
|
|
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" %}
|