@rmdes/indiekit-endpoint-activitypub 1.0.20 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +65 -6
- package/lib/activity-log.js +7 -3
- package/lib/batch-refollow.js +4 -2
- package/lib/controllers/featured-tags.js +71 -0
- package/lib/controllers/featured.js +117 -0
- package/lib/controllers/profile.js +10 -2
- package/lib/federation-setup.js +343 -19
- package/lib/inbox-listeners.js +39 -2
- package/locales/en.json +4 -0
- package/package.json +6 -4
- package/views/activitypub-featured-tags.njk +43 -0
- package/views/activitypub-featured.njk +52 -0
- package/views/activitypub-profile.njk +12 -0
package/index.js
CHANGED
|
@@ -21,6 +21,16 @@ import {
|
|
|
21
21
|
profileGetController,
|
|
22
22
|
profilePostController,
|
|
23
23
|
} from "./lib/controllers/profile.js";
|
|
24
|
+
import {
|
|
25
|
+
featuredGetController,
|
|
26
|
+
featuredPinController,
|
|
27
|
+
featuredUnpinController,
|
|
28
|
+
} from "./lib/controllers/featured.js";
|
|
29
|
+
import {
|
|
30
|
+
featuredTagsGetController,
|
|
31
|
+
featuredTagsAddController,
|
|
32
|
+
featuredTagsRemoveController,
|
|
33
|
+
} from "./lib/controllers/featured-tags.js";
|
|
24
34
|
import {
|
|
25
35
|
refollowPauseController,
|
|
26
36
|
refollowResumeController,
|
|
@@ -41,6 +51,9 @@ const defaults = {
|
|
|
41
51
|
alsoKnownAs: "",
|
|
42
52
|
activityRetentionDays: 90,
|
|
43
53
|
storeRawActivities: false,
|
|
54
|
+
redisUrl: "",
|
|
55
|
+
parallelWorkers: 5,
|
|
56
|
+
actorType: "Person",
|
|
44
57
|
};
|
|
45
58
|
|
|
46
59
|
export default class ActivityPubEndpoint {
|
|
@@ -135,6 +148,12 @@ export default class ActivityPubEndpoint {
|
|
|
135
148
|
router.get("/admin/followers", followersController(mp));
|
|
136
149
|
router.get("/admin/following", followingController(mp));
|
|
137
150
|
router.get("/admin/activities", activitiesController(mp));
|
|
151
|
+
router.get("/admin/featured", featuredGetController(mp));
|
|
152
|
+
router.post("/admin/featured/pin", featuredPinController());
|
|
153
|
+
router.post("/admin/featured/unpin", featuredUnpinController());
|
|
154
|
+
router.get("/admin/tags", featuredTagsGetController(mp));
|
|
155
|
+
router.post("/admin/tags/add", featuredTagsAddController());
|
|
156
|
+
router.post("/admin/tags/remove", featuredTagsRemoveController());
|
|
138
157
|
router.get("/admin/profile", profileGetController(mp));
|
|
139
158
|
router.post("/admin/profile", profilePostController(mp));
|
|
140
159
|
router.get("/admin/migrate", migrateGetController(mp, this.options));
|
|
@@ -265,7 +284,7 @@ export default class ActivityPubEndpoint {
|
|
|
265
284
|
|
|
266
285
|
const ctx = self._federation.createContext(
|
|
267
286
|
new URL(self._publicationUrl),
|
|
268
|
-
{},
|
|
287
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
269
288
|
);
|
|
270
289
|
|
|
271
290
|
// For replies, resolve the original post author for proper
|
|
@@ -326,11 +345,16 @@ export default class ActivityPubEndpoint {
|
|
|
326
345
|
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
|
327
346
|
);
|
|
328
347
|
|
|
329
|
-
// Send to followers
|
|
348
|
+
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
|
|
330
349
|
await ctx.sendActivity(
|
|
331
350
|
{ identifier: handle },
|
|
332
351
|
"followers",
|
|
333
352
|
activity,
|
|
353
|
+
{
|
|
354
|
+
preferSharedInbox: true,
|
|
355
|
+
syncCollection: true,
|
|
356
|
+
orderingKey: properties.url,
|
|
357
|
+
},
|
|
334
358
|
);
|
|
335
359
|
|
|
336
360
|
// For replies, also deliver to the original post author's inbox
|
|
@@ -341,6 +365,7 @@ export default class ActivityPubEndpoint {
|
|
|
341
365
|
{ identifier: handle },
|
|
342
366
|
replyToActor.recipient,
|
|
343
367
|
activity,
|
|
368
|
+
{ orderingKey: properties.url },
|
|
344
369
|
);
|
|
345
370
|
console.info(
|
|
346
371
|
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
|
|
@@ -407,7 +432,7 @@ export default class ActivityPubEndpoint {
|
|
|
407
432
|
const handle = this.options.actor.handle;
|
|
408
433
|
const ctx = this._federation.createContext(
|
|
409
434
|
new URL(this._publicationUrl),
|
|
410
|
-
{},
|
|
435
|
+
{ handle, publicationUrl: this._publicationUrl },
|
|
411
436
|
);
|
|
412
437
|
|
|
413
438
|
// Resolve the remote actor to get their inbox
|
|
@@ -422,7 +447,9 @@ export default class ActivityPubEndpoint {
|
|
|
422
447
|
object: new URL(actorUrl),
|
|
423
448
|
});
|
|
424
449
|
|
|
425
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, follow
|
|
450
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
451
|
+
orderingKey: actorUrl,
|
|
452
|
+
});
|
|
426
453
|
|
|
427
454
|
// Store in ap_following
|
|
428
455
|
const name =
|
|
@@ -501,7 +528,7 @@ export default class ActivityPubEndpoint {
|
|
|
501
528
|
const handle = this.options.actor.handle;
|
|
502
529
|
const ctx = this._federation.createContext(
|
|
503
530
|
new URL(this._publicationUrl),
|
|
504
|
-
{},
|
|
531
|
+
{ handle, publicationUrl: this._publicationUrl },
|
|
505
532
|
);
|
|
506
533
|
|
|
507
534
|
const remoteActor = await ctx.lookupObject(actorUrl);
|
|
@@ -530,7 +557,9 @@ export default class ActivityPubEndpoint {
|
|
|
530
557
|
object: follow,
|
|
531
558
|
});
|
|
532
559
|
|
|
533
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, undo
|
|
560
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
|
|
561
|
+
orderingKey: actorUrl,
|
|
562
|
+
});
|
|
534
563
|
await this._collections.ap_following.deleteOne({ actorUrl });
|
|
535
564
|
|
|
536
565
|
console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
|
|
@@ -585,6 +614,8 @@ export default class ActivityPubEndpoint {
|
|
|
585
614
|
Indiekit.addCollection("ap_keys");
|
|
586
615
|
Indiekit.addCollection("ap_kv");
|
|
587
616
|
Indiekit.addCollection("ap_profile");
|
|
617
|
+
Indiekit.addCollection("ap_featured");
|
|
618
|
+
Indiekit.addCollection("ap_featured_tags");
|
|
588
619
|
|
|
589
620
|
// Store collection references (posts resolved lazily)
|
|
590
621
|
const indiekitCollections = Indiekit.collections;
|
|
@@ -595,6 +626,8 @@ export default class ActivityPubEndpoint {
|
|
|
595
626
|
ap_keys: indiekitCollections.get("ap_keys"),
|
|
596
627
|
ap_kv: indiekitCollections.get("ap_kv"),
|
|
597
628
|
ap_profile: indiekitCollections.get("ap_profile"),
|
|
629
|
+
ap_featured: indiekitCollections.get("ap_featured"),
|
|
630
|
+
ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
|
|
598
631
|
get posts() {
|
|
599
632
|
return indiekitCollections.get("posts");
|
|
600
633
|
},
|
|
@@ -617,6 +650,28 @@ export default class ActivityPubEndpoint {
|
|
|
617
650
|
);
|
|
618
651
|
}
|
|
619
652
|
|
|
653
|
+
// Performance indexes for inbox handlers and batch refollow
|
|
654
|
+
this._collections.ap_followers.createIndex(
|
|
655
|
+
{ actorUrl: 1 },
|
|
656
|
+
{ unique: true, background: true },
|
|
657
|
+
);
|
|
658
|
+
this._collections.ap_following.createIndex(
|
|
659
|
+
{ actorUrl: 1 },
|
|
660
|
+
{ unique: true, background: true },
|
|
661
|
+
);
|
|
662
|
+
this._collections.ap_following.createIndex(
|
|
663
|
+
{ source: 1 },
|
|
664
|
+
{ background: true },
|
|
665
|
+
);
|
|
666
|
+
this._collections.ap_activities.createIndex(
|
|
667
|
+
{ objectUrl: 1 },
|
|
668
|
+
{ background: true },
|
|
669
|
+
);
|
|
670
|
+
this._collections.ap_activities.createIndex(
|
|
671
|
+
{ type: 1, actorUrl: 1, objectUrl: 1 },
|
|
672
|
+
{ background: true },
|
|
673
|
+
);
|
|
674
|
+
|
|
620
675
|
// Seed actor profile from config on first run
|
|
621
676
|
this._seedProfile().catch((error) => {
|
|
622
677
|
console.warn("[ActivityPub] Profile seed failed:", error.message);
|
|
@@ -628,6 +683,10 @@ export default class ActivityPubEndpoint {
|
|
|
628
683
|
mountPath: this.options.mountPath,
|
|
629
684
|
handle: this.options.actor.handle,
|
|
630
685
|
storeRawActivities: this.options.storeRawActivities,
|
|
686
|
+
redisUrl: this.options.redisUrl,
|
|
687
|
+
publicationUrl: this._publicationUrl,
|
|
688
|
+
parallelWorkers: this.options.parallelWorkers,
|
|
689
|
+
actorType: this.options.actorType,
|
|
631
690
|
});
|
|
632
691
|
|
|
633
692
|
this._federation = federation;
|
package/lib/activity-log.js
CHANGED
|
@@ -19,12 +19,16 @@
|
|
|
19
19
|
* @param {string} [record.content] - Content excerpt
|
|
20
20
|
* @param {string} record.summary - Human-readable summary
|
|
21
21
|
*/
|
|
22
|
-
export async function logActivity(collection, record) {
|
|
22
|
+
export async function logActivity(collection, record, options = {}) {
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
const doc = {
|
|
25
25
|
...record,
|
|
26
26
|
receivedAt: new Date().toISOString(),
|
|
27
|
-
}
|
|
27
|
+
};
|
|
28
|
+
if (options.rawJson) {
|
|
29
|
+
doc.rawJson = options.rawJson;
|
|
30
|
+
}
|
|
31
|
+
await collection.insertOne(doc);
|
|
28
32
|
} catch (error) {
|
|
29
33
|
console.warn("[ActivityPub] Failed to log activity:", error.message);
|
|
30
34
|
}
|
package/lib/batch-refollow.js
CHANGED
|
@@ -225,7 +225,7 @@ async function processOneFollow(options, entry) {
|
|
|
225
225
|
const { federation, collections, handle, publicationUrl } = options;
|
|
226
226
|
|
|
227
227
|
try {
|
|
228
|
-
const ctx = federation.createContext(new URL(publicationUrl), {});
|
|
228
|
+
const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl });
|
|
229
229
|
|
|
230
230
|
// Resolve the remote actor
|
|
231
231
|
const remoteActor = await ctx.lookupObject(entry.actorUrl);
|
|
@@ -242,7 +242,9 @@ async function processOneFollow(options, entry) {
|
|
|
242
242
|
object: new URL(canonicalUrl),
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, follow
|
|
245
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
246
|
+
orderingKey: canonicalUrl,
|
|
247
|
+
});
|
|
246
248
|
|
|
247
249
|
// Mark as sent — update actorUrl to canonical form so Accept handler
|
|
248
250
|
// can match when the remote server responds
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured tags controller — list, add, and remove featured hashtags.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function featuredTagsGetController(mountPath) {
|
|
6
|
+
return async (request, response, next) => {
|
|
7
|
+
try {
|
|
8
|
+
const { application } = request.app.locals;
|
|
9
|
+
const collection = application?.collections?.get("ap_featured_tags");
|
|
10
|
+
|
|
11
|
+
const tags = collection
|
|
12
|
+
? await collection.find().sort({ addedAt: -1 }).toArray()
|
|
13
|
+
: [];
|
|
14
|
+
|
|
15
|
+
response.render("activitypub-featured-tags", {
|
|
16
|
+
title:
|
|
17
|
+
response.locals.__("activitypub.featuredTags") || "Featured Tags",
|
|
18
|
+
tags,
|
|
19
|
+
mountPath,
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
next(error);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function featuredTagsAddController() {
|
|
28
|
+
return async (request, response, next) => {
|
|
29
|
+
try {
|
|
30
|
+
const { application } = request.app.locals;
|
|
31
|
+
const collection = application?.collections?.get("ap_featured_tags");
|
|
32
|
+
if (!collection) return response.status(500).send("No collection");
|
|
33
|
+
|
|
34
|
+
let { tag } = request.body;
|
|
35
|
+
if (!tag) return response.status(400).send("Missing tag");
|
|
36
|
+
|
|
37
|
+
// Normalize: strip leading # and lowercase
|
|
38
|
+
tag = tag.replace(/^#/, "").toLowerCase().trim();
|
|
39
|
+
if (!tag) return response.status(400).send("Invalid tag");
|
|
40
|
+
|
|
41
|
+
await collection.updateOne(
|
|
42
|
+
{ tag },
|
|
43
|
+
{ $set: { tag, addedAt: new Date().toISOString() } },
|
|
44
|
+
{ upsert: true },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
response.redirect("back");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
next(error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function featuredTagsRemoveController() {
|
|
55
|
+
return async (request, response, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const { application } = request.app.locals;
|
|
58
|
+
const collection = application?.collections?.get("ap_featured_tags");
|
|
59
|
+
if (!collection) return response.status(500).send("No collection");
|
|
60
|
+
|
|
61
|
+
const { tag } = request.body;
|
|
62
|
+
if (!tag) return response.status(400).send("Missing tag");
|
|
63
|
+
|
|
64
|
+
await collection.deleteOne({ tag });
|
|
65
|
+
|
|
66
|
+
response.redirect("back");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
next(error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured (pinned) posts controller — list, pin, and unpin posts.
|
|
3
|
+
*/
|
|
4
|
+
const MAX_PINS = 5;
|
|
5
|
+
|
|
6
|
+
export function featuredGetController(mountPath) {
|
|
7
|
+
return async (request, response, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { application } = request.app.locals;
|
|
10
|
+
const featuredCollection = application?.collections?.get("ap_featured");
|
|
11
|
+
const postsCollection = application?.collections?.get("posts");
|
|
12
|
+
|
|
13
|
+
const pinnedDocs = featuredCollection
|
|
14
|
+
? await featuredCollection.find().sort({ pinnedAt: -1 }).toArray()
|
|
15
|
+
: [];
|
|
16
|
+
|
|
17
|
+
// Enrich pinned posts with title/type from posts collection
|
|
18
|
+
const pinned = [];
|
|
19
|
+
for (const doc of pinnedDocs) {
|
|
20
|
+
let title = doc.postUrl;
|
|
21
|
+
let postType = "note";
|
|
22
|
+
if (postsCollection) {
|
|
23
|
+
const post = await postsCollection.findOne({
|
|
24
|
+
"properties.url": doc.postUrl,
|
|
25
|
+
});
|
|
26
|
+
if (post?.properties) {
|
|
27
|
+
title =
|
|
28
|
+
post.properties.name ||
|
|
29
|
+
post.properties.content?.text?.slice(0, 80) ||
|
|
30
|
+
doc.postUrl;
|
|
31
|
+
postType = post.properties["post-type"] || "note";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
pinned.push({ ...doc, title, postType });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Get recent posts for the "pin" dropdown
|
|
38
|
+
const recentPosts = postsCollection
|
|
39
|
+
? await postsCollection
|
|
40
|
+
.find()
|
|
41
|
+
.sort({ "properties.published": -1 })
|
|
42
|
+
.limit(20)
|
|
43
|
+
.toArray()
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
const pinnedUrls = new Set(pinnedDocs.map((d) => d.postUrl));
|
|
47
|
+
const availablePosts = recentPosts
|
|
48
|
+
.filter((p) => p.properties?.url && !pinnedUrls.has(p.properties.url))
|
|
49
|
+
.map((p) => ({
|
|
50
|
+
url: p.properties.url,
|
|
51
|
+
title:
|
|
52
|
+
p.properties.name ||
|
|
53
|
+
p.properties.content?.text?.slice(0, 80) ||
|
|
54
|
+
p.properties.url,
|
|
55
|
+
postType: p.properties["post-type"] || "note",
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
response.render("activitypub-featured", {
|
|
59
|
+
title: response.locals.__("activitypub.featured") || "Pinned Posts",
|
|
60
|
+
pinned,
|
|
61
|
+
availablePosts,
|
|
62
|
+
maxPins: MAX_PINS,
|
|
63
|
+
canPin: pinned.length < MAX_PINS,
|
|
64
|
+
mountPath,
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
next(error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function featuredPinController() {
|
|
73
|
+
return async (request, response, next) => {
|
|
74
|
+
try {
|
|
75
|
+
const { application } = request.app.locals;
|
|
76
|
+
const collection = application?.collections?.get("ap_featured");
|
|
77
|
+
if (!collection) return response.status(500).send("No collection");
|
|
78
|
+
|
|
79
|
+
const { postUrl } = request.body;
|
|
80
|
+
if (!postUrl) return response.status(400).send("Missing postUrl");
|
|
81
|
+
|
|
82
|
+
const count = await collection.countDocuments();
|
|
83
|
+
if (count >= MAX_PINS) {
|
|
84
|
+
return response.status(400).send("Maximum pins reached");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await collection.updateOne(
|
|
88
|
+
{ postUrl },
|
|
89
|
+
{ $set: { postUrl, pinnedAt: new Date().toISOString() } },
|
|
90
|
+
{ upsert: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
response.redirect("back");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
next(error);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function featuredUnpinController() {
|
|
101
|
+
return async (request, response, next) => {
|
|
102
|
+
try {
|
|
103
|
+
const { application } = request.app.locals;
|
|
104
|
+
const collection = application?.collections?.get("ap_featured");
|
|
105
|
+
if (!collection) return response.status(500).send("No collection");
|
|
106
|
+
|
|
107
|
+
const { postUrl } = request.body;
|
|
108
|
+
if (!postUrl) return response.status(400).send("Missing postUrl");
|
|
109
|
+
|
|
110
|
+
await collection.deleteOne({ postUrl });
|
|
111
|
+
|
|
112
|
+
response.redirect("back");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
next(error);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -36,8 +36,15 @@ export function profilePostController(mountPath) {
|
|
|
36
36
|
return next(new Error("ap_profile collection not available"));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const {
|
|
40
|
-
|
|
39
|
+
const {
|
|
40
|
+
name,
|
|
41
|
+
summary,
|
|
42
|
+
url,
|
|
43
|
+
icon,
|
|
44
|
+
image,
|
|
45
|
+
manuallyApprovesFollowers,
|
|
46
|
+
authorizedFetch,
|
|
47
|
+
} = request.body;
|
|
41
48
|
|
|
42
49
|
const update = {
|
|
43
50
|
$set: {
|
|
@@ -47,6 +54,7 @@ export function profilePostController(mountPath) {
|
|
|
47
54
|
icon: icon?.trim() || "",
|
|
48
55
|
image: image?.trim() || "",
|
|
49
56
|
manuallyApprovesFollowers: manuallyApprovesFollowers === "true",
|
|
57
|
+
authorizedFetch: authorizedFetch === "true",
|
|
50
58
|
updatedAt: new Date().toISOString(),
|
|
51
59
|
},
|
|
52
60
|
};
|
package/lib/federation-setup.js
CHANGED
|
@@ -7,20 +7,35 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
10
11
|
import { Temporal } from "@js-temporal/polyfill";
|
|
11
12
|
import {
|
|
13
|
+
Application,
|
|
14
|
+
Article,
|
|
15
|
+
Create,
|
|
12
16
|
Endpoints,
|
|
17
|
+
Group,
|
|
18
|
+
Hashtag,
|
|
13
19
|
Image,
|
|
14
20
|
InProcessMessageQueue,
|
|
21
|
+
Note,
|
|
22
|
+
Organization,
|
|
23
|
+
ParallelMessageQueue,
|
|
15
24
|
Person,
|
|
16
25
|
PropertyValue,
|
|
26
|
+
Service,
|
|
17
27
|
createFederation,
|
|
28
|
+
exportJwk,
|
|
18
29
|
generateCryptoKeyPair,
|
|
30
|
+
importJwk,
|
|
19
31
|
importSpki,
|
|
20
32
|
} from "@fedify/fedify";
|
|
21
33
|
import { configure, getConsoleSink } from "@logtape/logtape";
|
|
34
|
+
import { RedisMessageQueue } from "@fedify/redis";
|
|
35
|
+
import Redis from "ioredis";
|
|
22
36
|
import { MongoKvStore } from "./kv-store.js";
|
|
23
37
|
import { registerInboxListeners } from "./inbox-listeners.js";
|
|
38
|
+
import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js";
|
|
24
39
|
|
|
25
40
|
/**
|
|
26
41
|
* Create and configure a Fedify Federation instance.
|
|
@@ -41,8 +56,16 @@ export function setupFederation(options) {
|
|
|
41
56
|
mountPath,
|
|
42
57
|
handle,
|
|
43
58
|
storeRawActivities = false,
|
|
59
|
+
redisUrl = "",
|
|
60
|
+
publicationUrl = "",
|
|
61
|
+
parallelWorkers = 5,
|
|
62
|
+
actorType = "Person",
|
|
44
63
|
} = options;
|
|
45
64
|
|
|
65
|
+
// Map config string to Fedify actor class
|
|
66
|
+
const actorTypeMap = { Person, Service, Application, Organization, Group };
|
|
67
|
+
const ActorClass = actorTypeMap[actorType] || Person;
|
|
68
|
+
|
|
46
69
|
// Configure LogTape for Fedify delivery logging (once per process)
|
|
47
70
|
if (!_logtapeConfigured) {
|
|
48
71
|
_logtapeConfigured = true;
|
|
@@ -64,9 +87,28 @@ export function setupFederation(options) {
|
|
|
64
87
|
});
|
|
65
88
|
}
|
|
66
89
|
|
|
90
|
+
let queue;
|
|
91
|
+
if (redisUrl) {
|
|
92
|
+
const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl));
|
|
93
|
+
if (parallelWorkers > 1) {
|
|
94
|
+
queue = new ParallelMessageQueue(redisQueue, parallelWorkers);
|
|
95
|
+
console.info(
|
|
96
|
+
`[ActivityPub] Using Redis message queue with ${parallelWorkers} parallel workers`,
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
queue = redisQueue;
|
|
100
|
+
console.info("[ActivityPub] Using Redis message queue (single worker)");
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
queue = new InProcessMessageQueue();
|
|
104
|
+
console.warn(
|
|
105
|
+
"[ActivityPub] Using in-process message queue (not recommended for production)",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
67
109
|
const federation = createFederation({
|
|
68
110
|
kv: new MongoKvStore(collections.ap_kv),
|
|
69
|
-
queue
|
|
111
|
+
queue,
|
|
70
112
|
});
|
|
71
113
|
|
|
72
114
|
// --- Actor dispatcher ---
|
|
@@ -74,6 +116,26 @@ export function setupFederation(options) {
|
|
|
74
116
|
.setActorDispatcher(
|
|
75
117
|
`${mountPath}/users/{identifier}`,
|
|
76
118
|
async (ctx, identifier) => {
|
|
119
|
+
// Instance actor: Application-type actor for the domain itself
|
|
120
|
+
// Required for authorized fetch to avoid infinite loops
|
|
121
|
+
const hostname = ctx.url?.hostname || "";
|
|
122
|
+
if (identifier === hostname) {
|
|
123
|
+
const keyPairs = await ctx.getActorKeyPairs(identifier);
|
|
124
|
+
const appOptions = {
|
|
125
|
+
id: ctx.getActorUri(identifier),
|
|
126
|
+
preferredUsername: hostname,
|
|
127
|
+
name: hostname,
|
|
128
|
+
inbox: ctx.getInboxUri(identifier),
|
|
129
|
+
outbox: ctx.getOutboxUri(identifier),
|
|
130
|
+
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
131
|
+
};
|
|
132
|
+
if (keyPairs.length > 0) {
|
|
133
|
+
appOptions.publicKey = keyPairs[0].cryptographicKey;
|
|
134
|
+
appOptions.assertionMethods = keyPairs.map((k) => k.multikey);
|
|
135
|
+
}
|
|
136
|
+
return new Application(appOptions);
|
|
137
|
+
}
|
|
138
|
+
|
|
77
139
|
if (identifier !== handle) return null;
|
|
78
140
|
|
|
79
141
|
const profile = await getProfile(collections);
|
|
@@ -88,6 +150,9 @@ export function setupFederation(options) {
|
|
|
88
150
|
outbox: ctx.getOutboxUri(identifier),
|
|
89
151
|
followers: ctx.getFollowersUri(identifier),
|
|
90
152
|
following: ctx.getFollowingUri(identifier),
|
|
153
|
+
liked: ctx.getLikedUri(identifier),
|
|
154
|
+
featured: ctx.getFeaturedUri(identifier),
|
|
155
|
+
featuredTags: ctx.getFeaturedTagsUri(identifier),
|
|
91
156
|
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
92
157
|
manuallyApprovesFollowers:
|
|
93
158
|
profile.manuallyApprovesFollowers || false,
|
|
@@ -113,7 +178,7 @@ export function setupFederation(options) {
|
|
|
113
178
|
|
|
114
179
|
if (keyPairs.length > 0) {
|
|
115
180
|
personOptions.publicKey = keyPairs[0].cryptographicKey;
|
|
116
|
-
personOptions.
|
|
181
|
+
personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
|
|
117
182
|
}
|
|
118
183
|
|
|
119
184
|
if (profile.attachments?.length > 0) {
|
|
@@ -132,38 +197,115 @@ export function setupFederation(options) {
|
|
|
132
197
|
personOptions.published = Temporal.Instant.from(profile.createdAt);
|
|
133
198
|
}
|
|
134
199
|
|
|
135
|
-
return new
|
|
200
|
+
return new ActorClass(personOptions);
|
|
136
201
|
},
|
|
137
202
|
)
|
|
138
|
-
.mapHandle((_ctx, username) =>
|
|
203
|
+
.mapHandle((_ctx, username) => {
|
|
204
|
+
if (username === handle) return handle;
|
|
205
|
+
// Accept hostname as valid identifier for instance actor
|
|
206
|
+
if (publicationUrl) {
|
|
207
|
+
try {
|
|
208
|
+
const hostname = new URL(publicationUrl).hostname;
|
|
209
|
+
if (username === hostname) return hostname;
|
|
210
|
+
} catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
})
|
|
214
|
+
.mapAlias((_ctx, alias) => {
|
|
215
|
+
// Resolve profile URL and /@handle patterns via WebFinger
|
|
216
|
+
if (!publicationUrl) return null;
|
|
217
|
+
try {
|
|
218
|
+
const pub = new URL(publicationUrl);
|
|
219
|
+
if (alias.hostname !== pub.hostname) return null;
|
|
220
|
+
const path = alias.pathname.replace(/\/$/, "");
|
|
221
|
+
if (path === "" || path === `/@${handle}`) return handle;
|
|
222
|
+
} catch { /* ignore */ }
|
|
223
|
+
return null;
|
|
224
|
+
})
|
|
139
225
|
.setKeyPairsDispatcher(async (ctx, identifier) => {
|
|
140
|
-
|
|
226
|
+
// Allow key pairs for both the main actor and instance actor
|
|
227
|
+
const hostname = ctx.url?.hostname || "";
|
|
228
|
+
if (identifier !== handle && identifier !== hostname) return [];
|
|
141
229
|
|
|
142
230
|
const keyPairs = [];
|
|
143
231
|
|
|
144
|
-
//
|
|
145
|
-
const legacyKey = await collections.ap_keys.findOne({});
|
|
146
|
-
|
|
232
|
+
// --- Legacy RSA key pair (HTTP Signatures) ---
|
|
233
|
+
const legacyKey = await collections.ap_keys.findOne({ type: "rsa" });
|
|
234
|
+
// Fall back to old schema (no type field) for backward compat
|
|
235
|
+
const rsaDoc =
|
|
236
|
+
legacyKey ||
|
|
237
|
+
(await collections.ap_keys.findOne({
|
|
238
|
+
publicKeyPem: { $exists: true },
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) {
|
|
147
242
|
try {
|
|
148
|
-
const publicKey = await importSpki(
|
|
149
|
-
const privateKey = await importPkcs8Pem(
|
|
243
|
+
const publicKey = await importSpki(rsaDoc.publicKeyPem);
|
|
244
|
+
const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
|
|
150
245
|
keyPairs.push({ publicKey, privateKey });
|
|
151
246
|
} catch {
|
|
247
|
+
console.warn("[ActivityPub] Could not import legacy RSA keys");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Ed25519 key pair (Object Integrity Proofs) ---
|
|
252
|
+
// Load from DB or generate + persist on first use
|
|
253
|
+
let ed25519Doc = await collections.ap_keys.findOne({
|
|
254
|
+
type: "ed25519",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (ed25519Doc?.publicKeyJwk && ed25519Doc?.privateKeyJwk) {
|
|
258
|
+
try {
|
|
259
|
+
const publicKey = await importJwk(
|
|
260
|
+
ed25519Doc.publicKeyJwk,
|
|
261
|
+
"public",
|
|
262
|
+
);
|
|
263
|
+
const privateKey = await importJwk(
|
|
264
|
+
ed25519Doc.privateKeyJwk,
|
|
265
|
+
"private",
|
|
266
|
+
);
|
|
267
|
+
keyPairs.push({ publicKey, privateKey });
|
|
268
|
+
} catch (error) {
|
|
152
269
|
console.warn(
|
|
153
|
-
"[ActivityPub] Could not import
|
|
270
|
+
"[ActivityPub] Could not import Ed25519 keys, regenerating:",
|
|
271
|
+
error.message,
|
|
154
272
|
);
|
|
273
|
+
ed25519Doc = null; // Force regeneration below
|
|
155
274
|
}
|
|
156
275
|
}
|
|
157
276
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
277
|
+
if (!ed25519Doc) {
|
|
278
|
+
try {
|
|
279
|
+
const ed25519 = await generateCryptoKeyPair("Ed25519");
|
|
280
|
+
await collections.ap_keys.insertOne({
|
|
281
|
+
type: "ed25519",
|
|
282
|
+
publicKeyJwk: await exportJwk(ed25519.publicKey),
|
|
283
|
+
privateKeyJwk: await exportJwk(ed25519.privateKey),
|
|
284
|
+
createdAt: new Date().toISOString(),
|
|
285
|
+
});
|
|
286
|
+
keyPairs.push(ed25519);
|
|
287
|
+
console.info(
|
|
288
|
+
"[ActivityPub] Generated and persisted Ed25519 key pair",
|
|
289
|
+
);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.warn(
|
|
292
|
+
"[ActivityPub] Could not generate Ed25519 key pair:",
|
|
293
|
+
error.message,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
164
296
|
}
|
|
165
297
|
|
|
166
298
|
return keyPairs;
|
|
299
|
+
})
|
|
300
|
+
.authorize(async (ctx, identifier, signedKey, _signedKeyOwner) => {
|
|
301
|
+
// Instance actor is always publicly accessible (prevents infinite loops)
|
|
302
|
+
const hostname = ctx.url?.hostname || "";
|
|
303
|
+
if (identifier === hostname) return true;
|
|
304
|
+
// Check if authorized fetch is enabled
|
|
305
|
+
const profile = await getProfile(collections);
|
|
306
|
+
if (!profile.authorizedFetch) return true;
|
|
307
|
+
// When enabled, require a valid HTTP Signature
|
|
308
|
+
return signedKey != null;
|
|
167
309
|
});
|
|
168
310
|
|
|
169
311
|
// --- Inbox listeners ---
|
|
@@ -188,8 +330,22 @@ export function setupFederation(options) {
|
|
|
188
330
|
setupFollowers(federation, mountPath, handle, collections);
|
|
189
331
|
setupFollowing(federation, mountPath, handle, collections);
|
|
190
332
|
setupOutbox(federation, mountPath, handle, collections);
|
|
333
|
+
setupLiked(federation, mountPath, handle, collections);
|
|
334
|
+
setupFeatured(federation, mountPath, handle, collections, publicationUrl);
|
|
335
|
+
setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl);
|
|
336
|
+
|
|
337
|
+
// --- Object dispatchers (make posts dereferenceable) ---
|
|
338
|
+
setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl);
|
|
191
339
|
|
|
192
340
|
// --- NodeInfo ---
|
|
341
|
+
let softwareVersion = { major: 1, minor: 0, patch: 0 };
|
|
342
|
+
try {
|
|
343
|
+
const require = createRequire(import.meta.url);
|
|
344
|
+
const pkg = require("@indiekit/indiekit/package.json");
|
|
345
|
+
const [major, minor, patch] = pkg.version.split(/[.-]/).map(Number);
|
|
346
|
+
if (!Number.isNaN(major)) softwareVersion = { major, minor: minor || 0, patch: patch || 0 };
|
|
347
|
+
} catch { /* fallback to 1.0.0 */ }
|
|
348
|
+
|
|
193
349
|
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
|
|
194
350
|
const postsCount = collections.posts
|
|
195
351
|
? await collections.posts.countDocuments()
|
|
@@ -198,7 +354,7 @@ export function setupFederation(options) {
|
|
|
198
354
|
return {
|
|
199
355
|
software: {
|
|
200
356
|
name: "indiekit",
|
|
201
|
-
version:
|
|
357
|
+
version: softwareVersion,
|
|
202
358
|
},
|
|
203
359
|
protocols: ["activitypub"],
|
|
204
360
|
usage: {
|
|
@@ -228,8 +384,29 @@ function setupFollowers(federation, mountPath, handle, collections) {
|
|
|
228
384
|
`${mountPath}/users/{identifier}/followers`,
|
|
229
385
|
async (ctx, identifier, cursor) => {
|
|
230
386
|
if (identifier !== handle) return null;
|
|
387
|
+
|
|
388
|
+
// One-shot collection: when cursor is null, return ALL followers
|
|
389
|
+
// as Recipient objects so sendActivity("followers") can deliver.
|
|
390
|
+
// See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients
|
|
391
|
+
if (cursor == null) {
|
|
392
|
+
const docs = await collections.ap_followers
|
|
393
|
+
.find()
|
|
394
|
+
.sort({ followedAt: -1 })
|
|
395
|
+
.toArray();
|
|
396
|
+
return {
|
|
397
|
+
items: docs.map((f) => ({
|
|
398
|
+
id: new URL(f.actorUrl),
|
|
399
|
+
inboxId: f.inbox ? new URL(f.inbox) : null,
|
|
400
|
+
endpoints: f.sharedInbox
|
|
401
|
+
? { sharedInbox: new URL(f.sharedInbox) }
|
|
402
|
+
: null,
|
|
403
|
+
})),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Paginated collection: for remote browsing of /followers endpoint
|
|
231
408
|
const pageSize = 20;
|
|
232
|
-
const skip =
|
|
409
|
+
const skip = Number.parseInt(cursor, 10);
|
|
233
410
|
const docs = await collections.ap_followers
|
|
234
411
|
.find()
|
|
235
412
|
.sort({ followedAt: -1 })
|
|
@@ -282,6 +459,118 @@ function setupFollowing(federation, mountPath, handle, collections) {
|
|
|
282
459
|
.setFirstCursor(async () => "0");
|
|
283
460
|
}
|
|
284
461
|
|
|
462
|
+
function setupLiked(federation, mountPath, handle, collections) {
|
|
463
|
+
federation
|
|
464
|
+
.setLikedDispatcher(
|
|
465
|
+
`${mountPath}/users/{identifier}/liked`,
|
|
466
|
+
async (ctx, identifier, cursor) => {
|
|
467
|
+
if (identifier !== handle) return null;
|
|
468
|
+
if (!collections.posts) return { items: [] };
|
|
469
|
+
|
|
470
|
+
const pageSize = 20;
|
|
471
|
+
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
472
|
+
const query = { "properties.post-type": "like" };
|
|
473
|
+
const docs = await collections.posts
|
|
474
|
+
.find(query)
|
|
475
|
+
.sort({ "properties.published": -1 })
|
|
476
|
+
.skip(skip)
|
|
477
|
+
.limit(pageSize)
|
|
478
|
+
.toArray();
|
|
479
|
+
const total = await collections.posts.countDocuments(query);
|
|
480
|
+
|
|
481
|
+
const items = docs
|
|
482
|
+
.map((d) => {
|
|
483
|
+
const likeOf = d.properties?.["like-of"];
|
|
484
|
+
return likeOf ? new URL(likeOf) : null;
|
|
485
|
+
})
|
|
486
|
+
.filter(Boolean);
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
items,
|
|
490
|
+
nextCursor:
|
|
491
|
+
skip + pageSize < total ? String(skip + pageSize) : null,
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
.setCounter(async (ctx, identifier) => {
|
|
496
|
+
if (identifier !== handle) return 0;
|
|
497
|
+
if (!collections.posts) return 0;
|
|
498
|
+
return await collections.posts.countDocuments({
|
|
499
|
+
"properties.post-type": "like",
|
|
500
|
+
});
|
|
501
|
+
})
|
|
502
|
+
.setFirstCursor(async () => "0");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function setupFeatured(federation, mountPath, handle, collections, publicationUrl) {
|
|
506
|
+
federation.setFeaturedDispatcher(
|
|
507
|
+
`${mountPath}/users/{identifier}/featured`,
|
|
508
|
+
async (ctx, identifier) => {
|
|
509
|
+
if (identifier !== handle) return null;
|
|
510
|
+
if (!collections.ap_featured) return { items: [] };
|
|
511
|
+
|
|
512
|
+
const docs = await collections.ap_featured
|
|
513
|
+
.find()
|
|
514
|
+
.sort({ pinnedAt: -1 })
|
|
515
|
+
.toArray();
|
|
516
|
+
|
|
517
|
+
// Convert pinned post URLs to Fedify Note/Article objects
|
|
518
|
+
const items = [];
|
|
519
|
+
for (const doc of docs) {
|
|
520
|
+
if (!collections.posts) continue;
|
|
521
|
+
const post = await collections.posts.findOne({
|
|
522
|
+
"properties.url": doc.postUrl,
|
|
523
|
+
});
|
|
524
|
+
if (!post) continue;
|
|
525
|
+
const actorUrl = ctx.getActorUri(identifier).href;
|
|
526
|
+
const activity = jf2ToAS2Activity(
|
|
527
|
+
post.properties,
|
|
528
|
+
actorUrl,
|
|
529
|
+
publicationUrl,
|
|
530
|
+
);
|
|
531
|
+
if (activity instanceof Create) {
|
|
532
|
+
const obj = await activity.getObject();
|
|
533
|
+
if (obj) items.push(obj);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return { items };
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl) {
|
|
543
|
+
federation.setFeaturedTagsDispatcher(
|
|
544
|
+
`${mountPath}/users/{identifier}/tags`,
|
|
545
|
+
async (ctx, identifier) => {
|
|
546
|
+
if (identifier !== handle) return null;
|
|
547
|
+
if (!collections.ap_featured_tags) return { items: [] };
|
|
548
|
+
|
|
549
|
+
const docs = await collections.ap_featured_tags
|
|
550
|
+
.find()
|
|
551
|
+
.sort({ addedAt: -1 })
|
|
552
|
+
.toArray();
|
|
553
|
+
|
|
554
|
+
const baseUrl = publicationUrl
|
|
555
|
+
? publicationUrl.replace(/\/$/, "")
|
|
556
|
+
: ctx.url.origin;
|
|
557
|
+
|
|
558
|
+
const items = docs.map(
|
|
559
|
+
(doc) =>
|
|
560
|
+
new Hashtag({
|
|
561
|
+
name: `#${doc.tag}`,
|
|
562
|
+
href: new URL(
|
|
563
|
+
`/categories/${encodeURIComponent(doc.tag)}`,
|
|
564
|
+
baseUrl,
|
|
565
|
+
),
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
return { items };
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
285
574
|
function setupOutbox(federation, mountPath, handle, collections) {
|
|
286
575
|
federation
|
|
287
576
|
.setOutboxDispatcher(
|
|
@@ -334,6 +623,41 @@ function setupOutbox(federation, mountPath, handle, collections) {
|
|
|
334
623
|
.setFirstCursor(async () => "0");
|
|
335
624
|
}
|
|
336
625
|
|
|
626
|
+
function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
|
|
627
|
+
// Shared lookup: find post by URL path, convert to Fedify Note/Article
|
|
628
|
+
async function resolvePost(ctx, id) {
|
|
629
|
+
if (!collections.posts || !publicationUrl) return null;
|
|
630
|
+
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
|
|
631
|
+
const post = await collections.posts.findOne({ "properties.url": postUrl });
|
|
632
|
+
if (!post) return null;
|
|
633
|
+
const actorUrl = ctx.getActorUri(handle).href;
|
|
634
|
+
const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
|
|
635
|
+
// Only Create activities wrap Note/Article objects
|
|
636
|
+
if (!(activity instanceof Create)) return null;
|
|
637
|
+
return await activity.getObject();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
|
|
641
|
+
federation.setObjectDispatcher(
|
|
642
|
+
Note,
|
|
643
|
+
`${mountPath}/objects/note/{+id}`,
|
|
644
|
+
async (ctx, { id }) => {
|
|
645
|
+
const obj = await resolvePost(ctx, id);
|
|
646
|
+
return obj instanceof Note ? obj : null;
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
// Article dispatcher
|
|
651
|
+
federation.setObjectDispatcher(
|
|
652
|
+
Article,
|
|
653
|
+
`${mountPath}/objects/article/{+id}`,
|
|
654
|
+
async (ctx, { id }) => {
|
|
655
|
+
const obj = await resolvePost(ctx, id);
|
|
656
|
+
return obj instanceof Article ? obj : null;
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
337
661
|
// --- Helpers ---
|
|
338
662
|
|
|
339
663
|
async function getProfile(collections) {
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
Like,
|
|
17
17
|
Move,
|
|
18
18
|
Note,
|
|
19
|
+
Reject,
|
|
19
20
|
Remove,
|
|
20
21
|
Undo,
|
|
21
22
|
Update,
|
|
@@ -72,6 +73,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
72
73
|
actor: ctx.getActorUri(handle),
|
|
73
74
|
object: follow,
|
|
74
75
|
}),
|
|
76
|
+
{ orderingKey: followerUrl },
|
|
75
77
|
);
|
|
76
78
|
|
|
77
79
|
await logActivity(collections, storeRawActivities, {
|
|
@@ -160,6 +162,37 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
160
162
|
});
|
|
161
163
|
}
|
|
162
164
|
})
|
|
165
|
+
.on(Reject, async (ctx, reject) => {
|
|
166
|
+
const actorObj = await reject.getActor();
|
|
167
|
+
const actorUrl = actorObj?.id?.href || "";
|
|
168
|
+
if (!actorUrl) return;
|
|
169
|
+
|
|
170
|
+
// Mark rejected follow in ap_following
|
|
171
|
+
const result = await collections.ap_following.findOneAndUpdate(
|
|
172
|
+
{
|
|
173
|
+
actorUrl,
|
|
174
|
+
source: { $in: ["refollow:sent", "microsub-reader"] },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
$set: {
|
|
178
|
+
source: "rejected",
|
|
179
|
+
rejectedAt: new Date().toISOString(),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{ returnDocument: "after" },
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (result) {
|
|
186
|
+
const actorName = result.name || result.handle || actorUrl;
|
|
187
|
+
await logActivity(collections, storeRawActivities, {
|
|
188
|
+
direction: "inbound",
|
|
189
|
+
type: "Reject(Follow)",
|
|
190
|
+
actorUrl,
|
|
191
|
+
actorName,
|
|
192
|
+
summary: `${actorName} rejected our Follow`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
})
|
|
163
196
|
.on(Like, async (ctx, like) => {
|
|
164
197
|
const objectId = (await like.getObject())?.id?.href || "";
|
|
165
198
|
|
|
@@ -324,8 +357,12 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
324
357
|
* Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
|
|
325
358
|
* used throughout this file.
|
|
326
359
|
*/
|
|
327
|
-
async function logActivity(collections, storeRaw, record) {
|
|
328
|
-
await logActivityShared(
|
|
360
|
+
async function logActivity(collections, storeRaw, record, rawJson) {
|
|
361
|
+
await logActivityShared(
|
|
362
|
+
collections.ap_activities,
|
|
363
|
+
record,
|
|
364
|
+
storeRaw && rawJson ? { rawJson } : {},
|
|
365
|
+
);
|
|
329
366
|
}
|
|
330
367
|
|
|
331
368
|
// Cached ActivityPub channel ObjectId
|
package/locales/en.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
"followers": "Followers",
|
|
5
5
|
"following": "Following",
|
|
6
6
|
"activities": "Activity log",
|
|
7
|
+
"featured": "Pinned Posts",
|
|
8
|
+
"featuredTags": "Featured Tags",
|
|
7
9
|
"recentActivity": "Recent activity",
|
|
8
10
|
"noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
|
|
9
11
|
"noFollowers": "No followers yet.",
|
|
@@ -36,6 +38,8 @@
|
|
|
36
38
|
"imageHint": "URL to a banner image shown at the top of your profile",
|
|
37
39
|
"manualApprovalLabel": "Manually approve followers",
|
|
38
40
|
"manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
|
|
41
|
+
"authorizedFetchLabel": "Require authorized fetch (secure mode)",
|
|
42
|
+
"authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
|
|
39
43
|
"save": "Save profile",
|
|
40
44
|
"saved": "Profile saved. Changes are now visible to the fediverse."
|
|
41
45
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.22",
|
|
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",
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@fedify/
|
|
41
|
-
"@fedify/
|
|
40
|
+
"@fedify/express": "^1.10.3",
|
|
41
|
+
"@fedify/fedify": "^1.10.3",
|
|
42
|
+
"@fedify/redis": "^1.10.3",
|
|
42
43
|
"@js-temporal/polyfill": "^0.5.0",
|
|
43
|
-
"express": "^5.0.0"
|
|
44
|
+
"express": "^5.0.0",
|
|
45
|
+
"ioredis": "^5.9.3"
|
|
44
46
|
},
|
|
45
47
|
"peerDependencies": {
|
|
46
48
|
"@indiekit/error": "^1.0.0-beta.25",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
{% from "prose/macro.njk" import prose with context %}
|
|
5
|
+
|
|
6
|
+
{% block content %}
|
|
7
|
+
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
|
8
|
+
|
|
9
|
+
{% if tags.length > 0 %}
|
|
10
|
+
<table class="table">
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
<th>Tag</th>
|
|
14
|
+
<th>Added</th>
|
|
15
|
+
<th></th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
{% for item in tags %}
|
|
20
|
+
<tr>
|
|
21
|
+
<td>#{{ item.tag }}</td>
|
|
22
|
+
<td>{% if item.addedAt %}{{ item.addedAt | date("PPp") }}{% endif %}</td>
|
|
23
|
+
<td>
|
|
24
|
+
<form method="post" action="{{ mountPath }}/admin/tags/remove">
|
|
25
|
+
<input type="hidden" name="tag" value="{{ item.tag }}">
|
|
26
|
+
<button type="submit" class="button button--small">Remove</button>
|
|
27
|
+
</form>
|
|
28
|
+
</td>
|
|
29
|
+
</tr>
|
|
30
|
+
{% endfor %}
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
{% else %}
|
|
34
|
+
{{ prose({ text: "No featured tags yet. Add a hashtag to help others discover your content." }) }}
|
|
35
|
+
{% endif %}
|
|
36
|
+
|
|
37
|
+
<h2>Add a featured tag</h2>
|
|
38
|
+
<form method="post" action="{{ mountPath }}/admin/tags/add">
|
|
39
|
+
<label for="tag">Hashtag</label>
|
|
40
|
+
<input type="text" id="tag" name="tag" placeholder="indieweb" required>
|
|
41
|
+
<button type="submit" class="button">Add tag</button>
|
|
42
|
+
</form>
|
|
43
|
+
{% endblock %}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
{% from "prose/macro.njk" import prose with context %}
|
|
5
|
+
|
|
6
|
+
{% block content %}
|
|
7
|
+
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
|
8
|
+
|
|
9
|
+
{% if pinned.length > 0 %}
|
|
10
|
+
<table class="table">
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
<th>Post</th>
|
|
14
|
+
<th>Type</th>
|
|
15
|
+
<th>Pinned</th>
|
|
16
|
+
<th></th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
{% for item in pinned %}
|
|
21
|
+
<tr>
|
|
22
|
+
<td><a href="{{ item.postUrl }}">{{ item.title }}</a></td>
|
|
23
|
+
<td>{{ item.postType }}</td>
|
|
24
|
+
<td>{% if item.pinnedAt %}{{ item.pinnedAt | date("PPp") }}{% endif %}</td>
|
|
25
|
+
<td>
|
|
26
|
+
<form method="post" action="{{ mountPath }}/admin/featured/unpin">
|
|
27
|
+
<input type="hidden" name="postUrl" value="{{ item.postUrl }}">
|
|
28
|
+
<button type="submit" class="button button--small">Unpin</button>
|
|
29
|
+
</form>
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
{% else %}
|
|
36
|
+
{{ prose({ text: "No pinned posts yet." }) }}
|
|
37
|
+
{% endif %}
|
|
38
|
+
|
|
39
|
+
{% if canPin and availablePosts.length > 0 %}
|
|
40
|
+
<h2>Pin a post ({{ pinned.length }}/{{ maxPins }})</h2>
|
|
41
|
+
<form method="post" action="{{ mountPath }}/admin/featured/pin">
|
|
42
|
+
<select name="postUrl">
|
|
43
|
+
{% for post in availablePosts %}
|
|
44
|
+
<option value="{{ post.url }}">{{ post.title }} ({{ post.postType }})</option>
|
|
45
|
+
{% endfor %}
|
|
46
|
+
</select>
|
|
47
|
+
<button type="submit" class="button">Pin</button>
|
|
48
|
+
</form>
|
|
49
|
+
{% elif not canPin %}
|
|
50
|
+
{{ prose({ text: "Maximum of " + maxPins + " pinned posts reached. Unpin one to add another." }) }}
|
|
51
|
+
{% endif %}
|
|
52
|
+
{% endblock %}
|
|
@@ -69,6 +69,18 @@
|
|
|
69
69
|
values: ["true"] if profile.manuallyApprovesFollowers else []
|
|
70
70
|
}) }}
|
|
71
71
|
|
|
72
|
+
{{ checkboxes({
|
|
73
|
+
name: "authorizedFetch",
|
|
74
|
+
items: [
|
|
75
|
+
{
|
|
76
|
+
label: __("activitypub.profile.authorizedFetchLabel"),
|
|
77
|
+
value: "true",
|
|
78
|
+
hint: __("activitypub.profile.authorizedFetchHint")
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
values: ["true"] if profile.authorizedFetch else []
|
|
82
|
+
}) }}
|
|
83
|
+
|
|
72
84
|
{{ button({ text: __("activitypub.profile.save") }) }}
|
|
73
85
|
</form>
|
|
74
86
|
{% endblock %}
|