@rmdes/indiekit-endpoint-activitypub 0.1.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.
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <circle cx="12" cy="12" r="10"/>
3
+ <circle cx="12" cy="12" r="3"/>
4
+ <line x1="12" y1="2" x2="12" y2="5"/>
5
+ <line x1="12" y1="19" x2="12" y2="22"/>
6
+ <line x1="2" y1="12" x2="5" y2="12"/>
7
+ <line x1="19" y1="12" x2="22" y2="12"/>
8
+ <line x1="4.93" y1="4.93" x2="6.76" y2="6.76"/>
9
+ <line x1="17.24" y1="17.24" x2="19.07" y2="19.07"/>
10
+ <line x1="4.93" y1="19.07" x2="6.76" y2="17.24"/>
11
+ <line x1="17.24" y1="6.76" x2="19.07" y2="4.93"/>
12
+ </svg>
package/index.js ADDED
@@ -0,0 +1,376 @@
1
+ import path from "node:path";
2
+
3
+ import express from "express";
4
+
5
+ import { handleWebFinger } from "./lib/webfinger.js";
6
+ import { buildActorDocument } from "./lib/actor.js";
7
+ import { getOrCreateKeyPair } from "./lib/keys.js";
8
+ import { jf2ToActivityStreams, resolvePostUrl } from "./lib/jf2-to-as2.js";
9
+ import { createFederationHandler } from "./lib/federation.js";
10
+ import { dashboardController } from "./lib/controllers/dashboard.js";
11
+ import { followersController } from "./lib/controllers/followers.js";
12
+ import { followingController } from "./lib/controllers/following.js";
13
+ import { activitiesController } from "./lib/controllers/activities.js";
14
+ import { migrateGetController, migratePostController } from "./lib/controllers/migrate.js";
15
+
16
+ const defaults = {
17
+ mountPath: "/activitypub",
18
+ actor: {
19
+ handle: "rick",
20
+ name: "",
21
+ summary: "",
22
+ icon: "",
23
+ },
24
+ checked: true,
25
+ alsoKnownAs: "",
26
+ activityRetentionDays: 90, // Auto-delete activities older than this (0 = keep forever)
27
+ storeRawActivities: false, // Store full incoming JSON (enables debugging, costs storage)
28
+ };
29
+
30
+ export default class ActivityPubEndpoint {
31
+ name = "ActivityPub endpoint";
32
+
33
+ constructor(options = {}) {
34
+ this.options = { ...defaults, ...options };
35
+ this.options.actor = { ...defaults.actor, ...options.actor };
36
+ this.mountPath = this.options.mountPath;
37
+
38
+ // Set at init time when we have access to Indiekit
39
+ this._publicationUrl = "";
40
+ this._actorUrl = "";
41
+ this._collections = {};
42
+ this._federationHandler = null;
43
+ }
44
+
45
+ get navigationItems() {
46
+ return {
47
+ href: this.options.mountPath,
48
+ text: "activitypub.title",
49
+ requiresDatabase: true,
50
+ };
51
+ }
52
+
53
+ get filePath() {
54
+ return path.dirname(new URL(import.meta.url).pathname);
55
+ }
56
+
57
+ /**
58
+ * WebFinger routes — mounted at /.well-known/
59
+ */
60
+ get routesWellKnown() {
61
+ const router = express.Router(); // eslint-disable-line new-cap
62
+ const options = this.options;
63
+ const self = this;
64
+
65
+ router.get("/webfinger", (request, response) => {
66
+ const resource = request.query.resource;
67
+ if (!resource) {
68
+ return response.status(400).json({ error: "Missing resource parameter" });
69
+ }
70
+
71
+ const result = handleWebFinger(resource, {
72
+ handle: options.actor.handle,
73
+ hostname: new URL(self._publicationUrl).hostname,
74
+ actorUrl: self._actorUrl,
75
+ });
76
+
77
+ if (!result) {
78
+ return response.status(404).json({ error: "Resource not found" });
79
+ }
80
+
81
+ response.set("Content-Type", "application/jrd+json");
82
+ return response.json(result);
83
+ });
84
+
85
+ return router;
86
+ }
87
+
88
+ /**
89
+ * Public federation routes — mounted at mountPath, unauthenticated
90
+ */
91
+ get routesPublic() {
92
+ const router = express.Router(); // eslint-disable-line new-cap
93
+ const self = this;
94
+
95
+ // Actor document (fallback — primary is content negotiation on /)
96
+ router.get("/actor", async (request, response) => {
97
+ const actor = await self._getActorDocument();
98
+ if (!actor) {
99
+ return response.status(500).json({ error: "Actor not configured" });
100
+ }
101
+ response.set("Content-Type", "application/activity+json");
102
+ return response.json(actor);
103
+ });
104
+
105
+ // Inbox — receive incoming activities
106
+ router.post("/inbox", express.raw({ type: ["application/activity+json", "application/ld+json", "application/json"] }), async (request, response, next) => {
107
+ try {
108
+ if (self._federationHandler) {
109
+ return await self._federationHandler.handleInbox(request, response);
110
+ }
111
+ return response.status(202).json({ status: "accepted" });
112
+ } catch (error) {
113
+ next(error);
114
+ }
115
+ });
116
+
117
+ // Outbox — serve published posts as ActivityStreams
118
+ router.get("/outbox", async (request, response, next) => {
119
+ try {
120
+ if (self._federationHandler) {
121
+ return await self._federationHandler.handleOutbox(request, response);
122
+ }
123
+ response.set("Content-Type", "application/activity+json");
124
+ return response.json({
125
+ "@context": "https://www.w3.org/ns/activitystreams",
126
+ type: "OrderedCollection",
127
+ totalItems: 0,
128
+ orderedItems: [],
129
+ });
130
+ } catch (error) {
131
+ next(error);
132
+ }
133
+ });
134
+
135
+ // Followers collection
136
+ router.get("/followers", async (request, response, next) => {
137
+ try {
138
+ if (self._federationHandler) {
139
+ return await self._federationHandler.handleFollowers(request, response);
140
+ }
141
+ response.set("Content-Type", "application/activity+json");
142
+ return response.json({
143
+ "@context": "https://www.w3.org/ns/activitystreams",
144
+ type: "OrderedCollection",
145
+ totalItems: 0,
146
+ orderedItems: [],
147
+ });
148
+ } catch (error) {
149
+ next(error);
150
+ }
151
+ });
152
+
153
+ // Following collection
154
+ router.get("/following", async (request, response, next) => {
155
+ try {
156
+ if (self._federationHandler) {
157
+ return await self._federationHandler.handleFollowing(request, response);
158
+ }
159
+ response.set("Content-Type", "application/activity+json");
160
+ return response.json({
161
+ "@context": "https://www.w3.org/ns/activitystreams",
162
+ type: "OrderedCollection",
163
+ totalItems: 0,
164
+ orderedItems: [],
165
+ });
166
+ } catch (error) {
167
+ next(error);
168
+ }
169
+ });
170
+
171
+ return router;
172
+ }
173
+
174
+ /**
175
+ * Authenticated admin routes — mounted at mountPath, behind IndieAuth
176
+ */
177
+ get routes() {
178
+ const router = express.Router(); // eslint-disable-line new-cap
179
+ const mp = this.options.mountPath;
180
+
181
+ router.get("/", dashboardController(mp));
182
+ router.get("/admin/followers", followersController(mp));
183
+ router.get("/admin/following", followingController(mp));
184
+ router.get("/admin/activities", activitiesController(mp));
185
+ router.get("/admin/migrate", migrateGetController(mp));
186
+ router.post("/admin/migrate", migratePostController(mp, this.options));
187
+
188
+ return router;
189
+ }
190
+
191
+ /**
192
+ * Content negotiation handler — serves AS2 JSON for ActivityPub clients
193
+ * Registered as a separate endpoint with mountPath "/"
194
+ */
195
+ get contentNegotiationRoutes() {
196
+ const router = express.Router(); // eslint-disable-line new-cap
197
+ const self = this;
198
+
199
+ router.get("*", async (request, response, next) => {
200
+ const accept = request.headers.accept || "";
201
+ const isActivityPub =
202
+ accept.includes("application/activity+json") ||
203
+ accept.includes("application/ld+json");
204
+
205
+ if (!isActivityPub) {
206
+ return next();
207
+ }
208
+
209
+ try {
210
+ // Root URL — serve actor document
211
+ if (request.path === "/") {
212
+ const actor = await self._getActorDocument();
213
+ if (!actor) {
214
+ return next();
215
+ }
216
+ response.set("Content-Type", "application/activity+json");
217
+ return response.json(actor);
218
+ }
219
+
220
+ // Post URLs — look up in database and convert to AS2
221
+ const { application } = request.app.locals;
222
+ const postsCollection = application?.collections?.get("posts");
223
+ if (!postsCollection) {
224
+ return next();
225
+ }
226
+
227
+ // Try to find a post matching this URL path
228
+ const requestUrl = `${self._publicationUrl}${request.path.slice(1)}`;
229
+ const post = await postsCollection.findOne({
230
+ "properties.url": requestUrl,
231
+ });
232
+
233
+ if (!post) {
234
+ return next();
235
+ }
236
+
237
+ const activity = jf2ToActivityStreams(
238
+ post.properties,
239
+ self._actorUrl,
240
+ self._publicationUrl,
241
+ );
242
+
243
+ // Return the object, not the wrapping Create activity
244
+ const object = activity.object || activity;
245
+ response.set("Content-Type", "application/activity+json");
246
+ return response.json({
247
+ "@context": [
248
+ "https://www.w3.org/ns/activitystreams",
249
+ "https://w3id.org/security/v1",
250
+ ],
251
+ ...object,
252
+ });
253
+ } catch {
254
+ return next();
255
+ }
256
+ });
257
+
258
+ return router;
259
+ }
260
+
261
+ /**
262
+ * Build and cache the actor document
263
+ */
264
+ async _getActorDocument() {
265
+ const keysCollection = this._collections.ap_keys;
266
+ if (!keysCollection) {
267
+ return null;
268
+ }
269
+
270
+ const keyPair = await getOrCreateKeyPair(keysCollection, this._actorUrl);
271
+ return buildActorDocument({
272
+ actorUrl: this._actorUrl,
273
+ publicationUrl: this._publicationUrl,
274
+ mountPath: this.options.mountPath,
275
+ handle: this.options.actor.handle,
276
+ name: this.options.actor.name,
277
+ summary: this.options.actor.summary,
278
+ icon: this.options.actor.icon,
279
+ alsoKnownAs: this.options.alsoKnownAs,
280
+ publicKeyPem: keyPair.publicKeyPem,
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Syndicator — delivers posts to ActivityPub followers
286
+ */
287
+ get syndicator() {
288
+ const self = this;
289
+ return {
290
+ name: "ActivityPub syndicator",
291
+
292
+ get info() {
293
+ const hostname = self._publicationUrl
294
+ ? new URL(self._publicationUrl).hostname
295
+ : "example.com";
296
+ return {
297
+ checked: self.options.checked,
298
+ name: `@${self.options.actor.handle}@${hostname}`,
299
+ uid: self._publicationUrl || "https://example.com/",
300
+ service: {
301
+ name: "ActivityPub (Fediverse)",
302
+ photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
303
+ url: self._publicationUrl || "https://example.com/",
304
+ },
305
+ };
306
+ },
307
+
308
+ async syndicate(properties, publication) {
309
+ if (!self._federationHandler) {
310
+ return undefined;
311
+ }
312
+ return self._federationHandler.deliverToFollowers(
313
+ properties,
314
+ publication,
315
+ );
316
+ },
317
+ };
318
+ }
319
+
320
+ init(Indiekit) {
321
+ // Store publication URL for later use
322
+ this._publicationUrl = Indiekit.publication?.me
323
+ ? Indiekit.publication.me.endsWith("/")
324
+ ? Indiekit.publication.me
325
+ : `${Indiekit.publication.me}/`
326
+ : "";
327
+ this._actorUrl = this._publicationUrl;
328
+
329
+ // Register MongoDB collections
330
+ Indiekit.addCollection("ap_followers");
331
+ Indiekit.addCollection("ap_following");
332
+ Indiekit.addCollection("ap_activities");
333
+ Indiekit.addCollection("ap_keys");
334
+
335
+ // Store collection references for later use
336
+ this._collections = {
337
+ ap_followers: Indiekit.collections.get("ap_followers"),
338
+ ap_following: Indiekit.collections.get("ap_following"),
339
+ ap_activities: Indiekit.collections.get("ap_activities"),
340
+ ap_keys: Indiekit.collections.get("ap_keys"),
341
+ };
342
+
343
+ // Set up TTL index so ap_activities self-cleans (MongoDB handles expiry)
344
+ const retentionDays = this.options.activityRetentionDays;
345
+ if (retentionDays > 0) {
346
+ this._collections.ap_activities.createIndex(
347
+ { receivedAt: 1 },
348
+ { expireAfterSeconds: retentionDays * 86_400 },
349
+ );
350
+ }
351
+
352
+ // Initialize federation handler
353
+ this._federationHandler = createFederationHandler({
354
+ actorUrl: this._actorUrl,
355
+ publicationUrl: this._publicationUrl,
356
+ mountPath: this.options.mountPath,
357
+ actorConfig: this.options.actor,
358
+ alsoKnownAs: this.options.alsoKnownAs,
359
+ collections: this._collections,
360
+ storeRawActivities: this.options.storeRawActivities,
361
+ });
362
+
363
+ // Register as endpoint (adds routes)
364
+ Indiekit.addEndpoint(this);
365
+
366
+ // Register content negotiation handler as a virtual endpoint
367
+ Indiekit.addEndpoint({
368
+ name: "ActivityPub content negotiation",
369
+ mountPath: "/",
370
+ routesPublic: this.contentNegotiationRoutes,
371
+ });
372
+
373
+ // Register as syndicator (appears in post UI)
374
+ Indiekit.addSyndicator(this.syndicator);
375
+ }
376
+ }
package/lib/actor.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Build an ActivityPub Person actor document.
3
+ *
4
+ * This is the identity document that remote servers fetch to learn about
5
+ * this actor — it contains the profile, endpoints, and the public key
6
+ * used to verify HTTP Signatures on outbound activities.
7
+ *
8
+ * @param {object} options
9
+ * @param {string} options.actorUrl - Actor URL (also the Person id)
10
+ * @param {string} options.publicationUrl - Publication base URL (trailing slash)
11
+ * @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
12
+ * @param {string} options.handle - Preferred username (e.g. "rick")
13
+ * @param {string} options.name - Display name
14
+ * @param {string} options.summary - Bio / profile summary
15
+ * @param {string} options.icon - Avatar URL or path
16
+ * @param {string} options.alsoKnownAs - Previous account URL (for Mastodon migration)
17
+ * @param {string} options.publicKeyPem - PEM-encoded RSA public key
18
+ * @returns {object} ActivityStreams Person document
19
+ */
20
+ export function buildActorDocument(options) {
21
+ const {
22
+ actorUrl,
23
+ publicationUrl,
24
+ mountPath,
25
+ handle,
26
+ name,
27
+ summary,
28
+ icon,
29
+ alsoKnownAs,
30
+ publicKeyPem,
31
+ } = options;
32
+
33
+ const baseUrl = publicationUrl.replace(/\/$/, "");
34
+
35
+ const actor = {
36
+ "@context": [
37
+ "https://www.w3.org/ns/activitystreams",
38
+ "https://w3id.org/security/v1",
39
+ ],
40
+ type: "Person",
41
+ id: actorUrl,
42
+ preferredUsername: handle,
43
+ name: name || handle,
44
+ url: actorUrl,
45
+ inbox: `${baseUrl}${mountPath}/inbox`,
46
+ outbox: `${baseUrl}${mountPath}/outbox`,
47
+ followers: `${baseUrl}${mountPath}/followers`,
48
+ following: `${baseUrl}${mountPath}/following`,
49
+ publicKey: {
50
+ id: `${actorUrl}#main-key`,
51
+ owner: actorUrl,
52
+ publicKeyPem,
53
+ },
54
+ };
55
+
56
+ if (summary) {
57
+ actor.summary = summary;
58
+ }
59
+
60
+ if (icon) {
61
+ const iconUrl = icon.startsWith("http") ? icon : `${baseUrl}${icon.startsWith("/") ? "" : "/"}${icon}`;
62
+ actor.icon = {
63
+ type: "Image",
64
+ url: iconUrl,
65
+ };
66
+ }
67
+
68
+ if (alsoKnownAs) {
69
+ actor.alsoKnownAs = Array.isArray(alsoKnownAs)
70
+ ? alsoKnownAs
71
+ : [alsoKnownAs];
72
+ }
73
+
74
+ return actor;
75
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Activity log controller — paginated list of inbound/outbound activities.
3
+ */
4
+ const PAGE_SIZE = 20;
5
+
6
+ export function activitiesController(mountPath) {
7
+ return async (request, response, next) => {
8
+ try {
9
+ const { application } = request.app.locals;
10
+ const collection = application?.collections?.get("ap_activities");
11
+
12
+ if (!collection) {
13
+ return response.render("activities", {
14
+ title: response.locals.__("activitypub.activities"),
15
+ activities: [],
16
+ mountPath,
17
+ });
18
+ }
19
+
20
+ const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
21
+ const totalCount = await collection.countDocuments();
22
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
23
+
24
+ const activities = await collection
25
+ .find()
26
+ .sort({ receivedAt: -1 })
27
+ .skip((page - 1) * PAGE_SIZE)
28
+ .limit(PAGE_SIZE)
29
+ .toArray();
30
+
31
+ const cursor = buildCursor(page, totalPages, mountPath + "/admin/activities");
32
+
33
+ response.render("activities", {
34
+ title: response.locals.__("activitypub.activities"),
35
+ activities,
36
+ mountPath,
37
+ cursor,
38
+ });
39
+ } catch (error) {
40
+ next(error);
41
+ }
42
+ };
43
+ }
44
+
45
+ function buildCursor(page, totalPages, basePath) {
46
+ if (totalPages <= 1) return null;
47
+
48
+ return {
49
+ previous: page > 1
50
+ ? { href: `${basePath}?page=${page - 1}` }
51
+ : undefined,
52
+ next: page < totalPages
53
+ ? { href: `${basePath}?page=${page + 1}` }
54
+ : undefined,
55
+ };
56
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Dashboard controller — shows follower/following counts and recent activity.
3
+ */
4
+ export function dashboardController(mountPath) {
5
+ return async (request, response, next) => {
6
+ try {
7
+ const { application } = request.app.locals;
8
+ const followersCollection = application?.collections?.get("ap_followers");
9
+ const followingCollection = application?.collections?.get("ap_following");
10
+ const activitiesCollection =
11
+ application?.collections?.get("ap_activities");
12
+
13
+ const followerCount = followersCollection
14
+ ? await followersCollection.countDocuments()
15
+ : 0;
16
+ const followingCount = followingCollection
17
+ ? await followingCollection.countDocuments()
18
+ : 0;
19
+
20
+ const recentActivities = activitiesCollection
21
+ ? await activitiesCollection
22
+ .find()
23
+ .sort({ receivedAt: -1 })
24
+ .limit(10)
25
+ .toArray()
26
+ : [];
27
+
28
+ response.render("dashboard", {
29
+ title: response.locals.__("activitypub.title"),
30
+ followerCount,
31
+ followingCount,
32
+ recentActivities,
33
+ mountPath,
34
+ });
35
+ } catch (error) {
36
+ next(error);
37
+ }
38
+ };
39
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Followers list controller — paginated list of accounts following this actor.
3
+ */
4
+ const PAGE_SIZE = 20;
5
+
6
+ export function followersController(mountPath) {
7
+ return async (request, response, next) => {
8
+ try {
9
+ const { application } = request.app.locals;
10
+ const collection = application?.collections?.get("ap_followers");
11
+
12
+ if (!collection) {
13
+ return response.render("followers", {
14
+ title: response.locals.__("activitypub.followers"),
15
+ followers: [],
16
+ followerCount: 0,
17
+ mountPath,
18
+ });
19
+ }
20
+
21
+ const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
22
+ const totalCount = await collection.countDocuments();
23
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
24
+
25
+ const followers = await collection
26
+ .find()
27
+ .sort({ followedAt: -1 })
28
+ .skip((page - 1) * PAGE_SIZE)
29
+ .limit(PAGE_SIZE)
30
+ .toArray();
31
+
32
+ const cursor = buildCursor(page, totalPages, mountPath + "/admin/followers");
33
+
34
+ response.render("followers", {
35
+ title: response.locals.__("activitypub.followers"),
36
+ followers,
37
+ followerCount: totalCount,
38
+ mountPath,
39
+ cursor,
40
+ });
41
+ } catch (error) {
42
+ next(error);
43
+ }
44
+ };
45
+ }
46
+
47
+ function buildCursor(page, totalPages, basePath) {
48
+ if (totalPages <= 1) return null;
49
+
50
+ return {
51
+ previous: page > 1
52
+ ? { href: `${basePath}?page=${page - 1}` }
53
+ : undefined,
54
+ next: page < totalPages
55
+ ? { href: `${basePath}?page=${page + 1}` }
56
+ : undefined,
57
+ };
58
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Following list controller — paginated list of accounts this actor follows.
3
+ */
4
+ const PAGE_SIZE = 20;
5
+
6
+ export function followingController(mountPath) {
7
+ return async (request, response, next) => {
8
+ try {
9
+ const { application } = request.app.locals;
10
+ const collection = application?.collections?.get("ap_following");
11
+
12
+ if (!collection) {
13
+ return response.render("following", {
14
+ title: response.locals.__("activitypub.following"),
15
+ following: [],
16
+ followingCount: 0,
17
+ mountPath,
18
+ });
19
+ }
20
+
21
+ const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
22
+ const totalCount = await collection.countDocuments();
23
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
24
+
25
+ const following = await collection
26
+ .find()
27
+ .sort({ followedAt: -1 })
28
+ .skip((page - 1) * PAGE_SIZE)
29
+ .limit(PAGE_SIZE)
30
+ .toArray();
31
+
32
+ const cursor = buildCursor(page, totalPages, mountPath + "/admin/following");
33
+
34
+ response.render("following", {
35
+ title: response.locals.__("activitypub.following"),
36
+ following,
37
+ followingCount: totalCount,
38
+ mountPath,
39
+ cursor,
40
+ });
41
+ } catch (error) {
42
+ next(error);
43
+ }
44
+ };
45
+ }
46
+
47
+ function buildCursor(page, totalPages, basePath) {
48
+ if (totalPages <= 1) return null;
49
+
50
+ return {
51
+ previous: page > 1
52
+ ? { href: `${basePath}?page=${page - 1}` }
53
+ : undefined,
54
+ next: page < totalPages
55
+ ? { href: `${basePath}?page=${page + 1}` }
56
+ : undefined,
57
+ };
58
+ }