@rmdes/indiekit-endpoint-activitypub 0.1.10 → 1.0.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,119 @@
1
+ /**
2
+ * Express ↔ Fedify bridge.
3
+ *
4
+ * Converts Express requests to standard Request objects and delegates
5
+ * to federation.fetch(). We can't use @fedify/express's integrateFederation()
6
+ * because Indiekit plugins mount routes at a sub-path (e.g. /activitypub),
7
+ * which causes req.url to lose the mount prefix. Instead, we use
8
+ * req.originalUrl to preserve the full path that Fedify's URI templates expect.
9
+ */
10
+
11
+ import { Readable } from "node:stream";
12
+ import { Buffer } from "node:buffer";
13
+
14
+ /**
15
+ * Convert an Express request to a standard Request with the full URL.
16
+ *
17
+ * @param {import("express").Request} req - Express request
18
+ * @returns {Request} Standard Request object
19
+ */
20
+ export function fromExpressRequest(req) {
21
+ const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
22
+ const headers = new Headers();
23
+ for (const [key, value] of Object.entries(req.headers)) {
24
+ if (Array.isArray(value)) {
25
+ for (const v of value) headers.append(key, v);
26
+ } else if (typeof value === "string") {
27
+ headers.append(key, value);
28
+ }
29
+ }
30
+
31
+ return new Request(url, {
32
+ method: req.method,
33
+ headers,
34
+ duplex: "half",
35
+ body:
36
+ req.method === "GET" || req.method === "HEAD"
37
+ ? undefined
38
+ : Readable.toWeb(req),
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Send a standard Response back through Express.
44
+ *
45
+ * @param {import("express").Response} res - Express response
46
+ * @param {Response} response - Standard Response from federation.fetch()
47
+ */
48
+ async function sendFedifyResponse(res, response) {
49
+ res.status(response.status);
50
+ response.headers.forEach((value, key) => {
51
+ res.setHeader(key, value);
52
+ });
53
+
54
+ if (!response.body) {
55
+ res.end();
56
+ return;
57
+ }
58
+
59
+ const reader = response.body.getReader();
60
+ await new Promise((resolve) => {
61
+ function read({ done, value }) {
62
+ if (done) {
63
+ reader.releaseLock();
64
+ resolve();
65
+ return;
66
+ }
67
+ res.write(Buffer.from(value));
68
+ reader.read().then(read);
69
+ }
70
+ reader.read().then(read);
71
+ });
72
+ res.end();
73
+ }
74
+
75
+ /**
76
+ * Create Express middleware that delegates to Fedify's federation.fetch().
77
+ *
78
+ * On 404 (Fedify didn't match), calls next().
79
+ * On 406 (not acceptable), calls next() so Express can try other handlers.
80
+ * Otherwise, sends the Fedify response directly.
81
+ *
82
+ * @param {import("@fedify/fedify").Federation} federation
83
+ * @param {Function} contextDataFactory - (req) => contextData
84
+ * @returns {import("express").RequestHandler}
85
+ */
86
+ export function createFedifyMiddleware(federation, contextDataFactory) {
87
+ return async (req, res, next) => {
88
+ try {
89
+ const request = fromExpressRequest(req);
90
+ const contextData = await Promise.resolve(contextDataFactory(req));
91
+
92
+ let notFound = false;
93
+ let notAcceptable = false;
94
+
95
+ const response = await federation.fetch(request, {
96
+ contextData,
97
+ onNotFound: () => {
98
+ notFound = true;
99
+ return new Response("Not found", { status: 404 });
100
+ },
101
+ onNotAcceptable: () => {
102
+ notAcceptable = true;
103
+ return new Response("Not acceptable", {
104
+ status: 406,
105
+ headers: { "Content-Type": "text/plain", Vary: "Accept" },
106
+ });
107
+ },
108
+ });
109
+
110
+ if (notFound || notAcceptable) {
111
+ return next();
112
+ }
113
+
114
+ await sendFedifyResponse(res, response);
115
+ } catch (error) {
116
+ next(error);
117
+ }
118
+ };
119
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Fedify Federation setup — configures the Federation instance with all
3
+ * dispatchers, inbox listeners, and collection handlers.
4
+ *
5
+ * This replaces the hand-rolled federation.js, actor.js, keys.js, webfinger.js,
6
+ * and inbox.js with Fedify's battle-tested implementations.
7
+ */
8
+
9
+ import { Temporal } from "@js-temporal/polyfill";
10
+ import {
11
+ Endpoints,
12
+ Image,
13
+ InProcessMessageQueue,
14
+ Person,
15
+ PropertyValue,
16
+ createFederation,
17
+ importSpki,
18
+ } from "@fedify/fedify";
19
+ import { MongoKvStore } from "./kv-store.js";
20
+ import { registerInboxListeners } from "./inbox-listeners.js";
21
+
22
+ /**
23
+ * Create and configure a Fedify Federation instance.
24
+ *
25
+ * @param {object} options
26
+ * @param {object} options.collections - MongoDB collections
27
+ * @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
28
+ * @param {string} options.handle - Actor handle (e.g. "rick")
29
+ * @param {boolean} options.storeRawActivities - Whether to store full raw JSON
30
+ * @returns {{ federation: import("@fedify/fedify").Federation }}
31
+ */
32
+ export function setupFederation(options) {
33
+ const {
34
+ collections,
35
+ mountPath,
36
+ handle,
37
+ storeRawActivities = false,
38
+ } = options;
39
+
40
+ const federation = createFederation({
41
+ kv: new MongoKvStore(collections.ap_kv),
42
+ queue: new InProcessMessageQueue(),
43
+ });
44
+
45
+ // --- Actor dispatcher ---
46
+ federation
47
+ .setActorDispatcher(
48
+ `${mountPath}/users/{identifier}`,
49
+ async (ctx, identifier) => {
50
+ if (identifier !== handle) return null;
51
+
52
+ const profile = await getProfile(collections);
53
+ const keyPairs = await ctx.getActorKeyPairs(identifier);
54
+
55
+ const personOptions = {
56
+ id: ctx.getActorUri(identifier),
57
+ preferredUsername: identifier,
58
+ name: profile.name || identifier,
59
+ url: profile.url ? new URL(profile.url) : null,
60
+ inbox: ctx.getInboxUri(identifier),
61
+ outbox: ctx.getOutboxUri(identifier),
62
+ followers: ctx.getFollowersUri(identifier),
63
+ following: ctx.getFollowingUri(identifier),
64
+ endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
65
+ manuallyApprovesFollowers:
66
+ profile.manuallyApprovesFollowers || false,
67
+ };
68
+
69
+ if (profile.summary) {
70
+ personOptions.summary = profile.summary;
71
+ }
72
+
73
+ if (profile.icon) {
74
+ personOptions.icon = new Image({
75
+ url: new URL(profile.icon),
76
+ mediaType: guessImageMediaType(profile.icon),
77
+ });
78
+ }
79
+
80
+ if (profile.image) {
81
+ personOptions.image = new Image({
82
+ url: new URL(profile.image),
83
+ mediaType: guessImageMediaType(profile.image),
84
+ });
85
+ }
86
+
87
+ if (keyPairs.length > 0) {
88
+ personOptions.publicKey = keyPairs[0].cryptographicKey;
89
+ personOptions.assertionMethod = keyPairs[0].multikey;
90
+ }
91
+
92
+ if (profile.attachments?.length > 0) {
93
+ personOptions.attachments = profile.attachments.map(
94
+ (att) => new PropertyValue({ name: att.name, value: att.value }),
95
+ );
96
+ }
97
+
98
+ if (profile.alsoKnownAs?.length > 0) {
99
+ personOptions.alsoKnownAs = profile.alsoKnownAs.map(
100
+ (u) => new URL(u),
101
+ );
102
+ }
103
+
104
+ if (profile.createdAt) {
105
+ personOptions.published = Temporal.Instant.from(profile.createdAt);
106
+ }
107
+
108
+ return new Person(personOptions);
109
+ },
110
+ )
111
+ .setKeyPairsDispatcher(async (ctx, identifier) => {
112
+ if (identifier !== handle) return [];
113
+
114
+ const legacyKey = await collections.ap_keys.findOne({});
115
+ if (legacyKey?.publicKeyPem && legacyKey?.privateKeyPem) {
116
+ try {
117
+ const publicKey = await importSpki(legacyKey.publicKeyPem, "RSA");
118
+ const privateKey = await importPkcs8Pem(legacyKey.privateKeyPem);
119
+ return [{ publicKey, privateKey }];
120
+ } catch {
121
+ console.warn(
122
+ "[ActivityPub] Could not import legacy RSA keys, generating new key pairs",
123
+ );
124
+ }
125
+ }
126
+
127
+ return [];
128
+ });
129
+
130
+ // --- Inbox listeners ---
131
+ const inboxChain = federation.setInboxListeners(
132
+ `${mountPath}/users/{identifier}/inbox`,
133
+ `${mountPath}/inbox`,
134
+ );
135
+ registerInboxListeners(inboxChain, {
136
+ collections,
137
+ handle,
138
+ storeRawActivities,
139
+ });
140
+
141
+ // --- Collection dispatchers ---
142
+ setupFollowers(federation, mountPath, handle, collections);
143
+ setupFollowing(federation, mountPath, handle, collections);
144
+ setupOutbox(federation, mountPath, handle, collections);
145
+
146
+ // --- NodeInfo ---
147
+ federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
148
+ const postsCount = collections.posts
149
+ ? await collections.posts.countDocuments()
150
+ : 0;
151
+
152
+ return {
153
+ software: {
154
+ name: "indiekit",
155
+ version: { major: 1, minor: 0, patch: 0 },
156
+ },
157
+ protocols: ["activitypub"],
158
+ usage: {
159
+ users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
160
+ localPosts: postsCount,
161
+ localComments: 0,
162
+ },
163
+ };
164
+ });
165
+
166
+ return { federation };
167
+ }
168
+
169
+ // --- Collection setup helpers ---
170
+
171
+ function setupFollowers(federation, mountPath, handle, collections) {
172
+ federation
173
+ .setFollowersDispatcher(
174
+ `${mountPath}/users/{identifier}/followers`,
175
+ async (ctx, identifier, cursor) => {
176
+ if (identifier !== handle) return null;
177
+ const pageSize = 20;
178
+ const skip = cursor ? Number.parseInt(cursor, 10) : 0;
179
+ const docs = await collections.ap_followers
180
+ .find()
181
+ .sort({ followedAt: -1 })
182
+ .skip(skip)
183
+ .limit(pageSize)
184
+ .toArray();
185
+ const total = await collections.ap_followers.countDocuments();
186
+
187
+ return {
188
+ items: docs.map((f) => new URL(f.actorUrl)),
189
+ nextCursor:
190
+ skip + pageSize < total ? String(skip + pageSize) : null,
191
+ };
192
+ },
193
+ )
194
+ .setCounter(async (ctx, identifier) => {
195
+ if (identifier !== handle) return 0;
196
+ return await collections.ap_followers.countDocuments();
197
+ })
198
+ .setFirstCursor(async () => "0");
199
+ }
200
+
201
+ function setupFollowing(federation, mountPath, handle, collections) {
202
+ federation
203
+ .setFollowingDispatcher(
204
+ `${mountPath}/users/{identifier}/following`,
205
+ async (ctx, identifier, cursor) => {
206
+ if (identifier !== handle) return null;
207
+ const pageSize = 20;
208
+ const skip = cursor ? Number.parseInt(cursor, 10) : 0;
209
+ const docs = await collections.ap_following
210
+ .find()
211
+ .sort({ followedAt: -1 })
212
+ .skip(skip)
213
+ .limit(pageSize)
214
+ .toArray();
215
+ const total = await collections.ap_following.countDocuments();
216
+
217
+ return {
218
+ items: docs.map((f) => new URL(f.actorUrl)),
219
+ nextCursor:
220
+ skip + pageSize < total ? String(skip + pageSize) : null,
221
+ };
222
+ },
223
+ )
224
+ .setCounter(async (ctx, identifier) => {
225
+ if (identifier !== handle) return 0;
226
+ return await collections.ap_following.countDocuments();
227
+ })
228
+ .setFirstCursor(async () => "0");
229
+ }
230
+
231
+ function setupOutbox(federation, mountPath, handle, collections) {
232
+ federation
233
+ .setOutboxDispatcher(
234
+ `${mountPath}/users/{identifier}/outbox`,
235
+ async (ctx, identifier, cursor) => {
236
+ if (identifier !== handle) return null;
237
+
238
+ const postsCollection = collections.posts;
239
+ if (!postsCollection) return { items: [] };
240
+
241
+ const pageSize = 20;
242
+ const skip = cursor ? Number.parseInt(cursor, 10) : 0;
243
+ const total = await postsCollection.countDocuments();
244
+
245
+ const posts = await postsCollection
246
+ .find()
247
+ .sort({ "properties.published": -1 })
248
+ .skip(skip)
249
+ .limit(pageSize)
250
+ .toArray();
251
+
252
+ const { jf2ToAS2Activity } = await import("./jf2-to-as2.js");
253
+ const items = posts
254
+ .map((post) => {
255
+ try {
256
+ return jf2ToAS2Activity(
257
+ post.properties,
258
+ ctx.getActorUri(identifier).href,
259
+ collections._publicationUrl,
260
+ );
261
+ } catch {
262
+ return null;
263
+ }
264
+ })
265
+ .filter(Boolean);
266
+
267
+ return {
268
+ items,
269
+ nextCursor:
270
+ skip + pageSize < total ? String(skip + pageSize) : null,
271
+ };
272
+ },
273
+ )
274
+ .setCounter(async (ctx, identifier) => {
275
+ if (identifier !== handle) return 0;
276
+ const postsCollection = collections.posts;
277
+ if (!postsCollection) return 0;
278
+ return await postsCollection.countDocuments();
279
+ })
280
+ .setFirstCursor(async () => "0");
281
+ }
282
+
283
+ // --- Helpers ---
284
+
285
+ async function getProfile(collections) {
286
+ const doc = await collections.ap_profile.findOne({});
287
+ return doc || {};
288
+ }
289
+
290
+ /**
291
+ * Import a PKCS#8 PEM private key using Web Crypto API.
292
+ * Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
293
+ */
294
+ async function importPkcs8Pem(pem) {
295
+ const lines = pem
296
+ .replace("-----BEGIN PRIVATE KEY-----", "")
297
+ .replace("-----END PRIVATE KEY-----", "")
298
+ .replace(/\s/g, "");
299
+ const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0));
300
+ return crypto.subtle.importKey(
301
+ "pkcs8",
302
+ der,
303
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
304
+ true,
305
+ ["sign"],
306
+ );
307
+ }
308
+
309
+ function guessImageMediaType(url) {
310
+ const ext = url.split(".").pop()?.toLowerCase();
311
+ const types = {
312
+ jpg: "image/jpeg",
313
+ jpeg: "image/jpeg",
314
+ png: "image/png",
315
+ gif: "image/gif",
316
+ webp: "image/webp",
317
+ svg: "image/svg+xml",
318
+ avif: "image/avif",
319
+ };
320
+ return types[ext] || "image/jpeg";
321
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Inbox listener registrations for the Fedify Federation instance.
3
+ *
4
+ * Each listener handles a specific ActivityPub activity type received
5
+ * in the actor's inbox (Follow, Undo, Like, Announce, Create, Delete, Move).
6
+ */
7
+
8
+ import {
9
+ Accept,
10
+ Announce,
11
+ Create,
12
+ Delete,
13
+ Follow,
14
+ Like,
15
+ Move,
16
+ Note,
17
+ Undo,
18
+ } from "@fedify/fedify";
19
+
20
+ /**
21
+ * Register all inbox listeners on a federation's inbox chain.
22
+ *
23
+ * @param {object} inboxChain - Return value of federation.setInboxListeners()
24
+ * @param {object} options
25
+ * @param {object} options.collections - MongoDB collections
26
+ * @param {string} options.handle - Actor handle
27
+ * @param {boolean} options.storeRawActivities - Whether to store raw JSON
28
+ */
29
+ export function registerInboxListeners(inboxChain, options) {
30
+ const { collections, handle, storeRawActivities } = options;
31
+
32
+ inboxChain
33
+ .on(Follow, async (ctx, follow) => {
34
+ const followerActor = await follow.getActor();
35
+ if (!followerActor?.id) return;
36
+
37
+ const followerUrl = followerActor.id.href;
38
+ const followerName =
39
+ followerActor.name?.toString() ||
40
+ followerActor.preferredUsername?.toString() ||
41
+ followerUrl;
42
+
43
+ await collections.ap_followers.updateOne(
44
+ { actorUrl: followerUrl },
45
+ {
46
+ $set: {
47
+ actorUrl: followerUrl,
48
+ handle: followerActor.preferredUsername?.toString() || "",
49
+ name: followerName,
50
+ avatar: followerActor.icon
51
+ ? (await followerActor.icon)?.url?.href || ""
52
+ : "",
53
+ inbox: followerActor.inbox?.id?.href || "",
54
+ sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
55
+ followedAt: new Date().toISOString(),
56
+ },
57
+ },
58
+ { upsert: true },
59
+ );
60
+
61
+ // Auto-accept: send Accept back
62
+ await ctx.sendActivity(
63
+ { identifier: handle },
64
+ followerActor,
65
+ new Accept({
66
+ actor: ctx.getActorUri(handle),
67
+ object: follow,
68
+ }),
69
+ );
70
+
71
+ await logActivity(collections, storeRawActivities, {
72
+ direction: "inbound",
73
+ type: "Follow",
74
+ actorUrl: followerUrl,
75
+ actorName: followerName,
76
+ summary: `${followerName} followed you`,
77
+ });
78
+ })
79
+ .on(Undo, async (ctx, undo) => {
80
+ const actorObj = await undo.getActor();
81
+ const actorUrl = actorObj?.id?.href || "";
82
+ const inner = await undo.getObject();
83
+
84
+ if (inner instanceof Follow) {
85
+ await collections.ap_followers.deleteOne({ actorUrl });
86
+ await logActivity(collections, storeRawActivities, {
87
+ direction: "inbound",
88
+ type: "Undo(Follow)",
89
+ actorUrl,
90
+ summary: `${actorUrl} unfollowed you`,
91
+ });
92
+ } else if (inner instanceof Like) {
93
+ const objectId = (await inner.getObject())?.id?.href || "";
94
+ await collections.ap_activities.deleteOne({
95
+ type: "Like",
96
+ actorUrl,
97
+ objectUrl: objectId,
98
+ });
99
+ } else if (inner instanceof Announce) {
100
+ const objectId = (await inner.getObject())?.id?.href || "";
101
+ await collections.ap_activities.deleteOne({
102
+ type: "Announce",
103
+ actorUrl,
104
+ objectUrl: objectId,
105
+ });
106
+ } else {
107
+ const typeName = inner?.constructor?.name || "unknown";
108
+ await logActivity(collections, storeRawActivities, {
109
+ direction: "inbound",
110
+ type: `Undo(${typeName})`,
111
+ actorUrl,
112
+ summary: `${actorUrl} undid ${typeName}`,
113
+ });
114
+ }
115
+ })
116
+ .on(Like, async (ctx, like) => {
117
+ const actorObj = await like.getActor();
118
+ const actorUrl = actorObj?.id?.href || "";
119
+ const actorName =
120
+ actorObj?.name?.toString() ||
121
+ actorObj?.preferredUsername?.toString() ||
122
+ actorUrl;
123
+ const objectId = (await like.getObject())?.id?.href || "";
124
+
125
+ await logActivity(collections, storeRawActivities, {
126
+ direction: "inbound",
127
+ type: "Like",
128
+ actorUrl,
129
+ actorName,
130
+ objectUrl: objectId,
131
+ summary: `${actorName} liked ${objectId}`,
132
+ });
133
+ })
134
+ .on(Announce, async (ctx, announce) => {
135
+ const actorObj = await announce.getActor();
136
+ const actorUrl = actorObj?.id?.href || "";
137
+ const actorName =
138
+ actorObj?.name?.toString() ||
139
+ actorObj?.preferredUsername?.toString() ||
140
+ actorUrl;
141
+ const objectId = (await announce.getObject())?.id?.href || "";
142
+
143
+ await logActivity(collections, storeRawActivities, {
144
+ direction: "inbound",
145
+ type: "Announce",
146
+ actorUrl,
147
+ actorName,
148
+ objectUrl: objectId,
149
+ summary: `${actorName} boosted ${objectId}`,
150
+ });
151
+ })
152
+ .on(Create, async (ctx, create) => {
153
+ const object = await create.getObject();
154
+ if (!object) return;
155
+
156
+ const inReplyTo =
157
+ object instanceof Note
158
+ ? (await object.getInReplyTo())?.id?.href
159
+ : null;
160
+ if (!inReplyTo) return;
161
+
162
+ const actorObj = await create.getActor();
163
+ const actorUrl = actorObj?.id?.href || "";
164
+ const actorName =
165
+ actorObj?.name?.toString() ||
166
+ actorObj?.preferredUsername?.toString() ||
167
+ actorUrl;
168
+
169
+ await logActivity(collections, storeRawActivities, {
170
+ direction: "inbound",
171
+ type: "Reply",
172
+ actorUrl,
173
+ actorName,
174
+ objectUrl: object.id?.href || "",
175
+ summary: `${actorName} replied to ${inReplyTo}`,
176
+ });
177
+ })
178
+ .on(Delete, async (ctx, del) => {
179
+ const objectId = (await del.getObject())?.id?.href || "";
180
+ if (objectId) {
181
+ await collections.ap_activities.deleteMany({ objectUrl: objectId });
182
+ }
183
+ })
184
+ .on(Move, async (ctx, move) => {
185
+ const oldActorObj = await move.getActor();
186
+ const oldActorUrl = oldActorObj?.id?.href || "";
187
+ const target = await move.getTarget();
188
+ const newActorUrl = target?.id?.href || "";
189
+
190
+ if (oldActorUrl && newActorUrl) {
191
+ await collections.ap_followers.updateOne(
192
+ { actorUrl: oldActorUrl },
193
+ { $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
194
+ );
195
+ }
196
+
197
+ await logActivity(collections, storeRawActivities, {
198
+ direction: "inbound",
199
+ type: "Move",
200
+ actorUrl: oldActorUrl,
201
+ objectUrl: newActorUrl,
202
+ summary: `${oldActorUrl} moved to ${newActorUrl}`,
203
+ });
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Log an activity to the ap_activities collection.
209
+ */
210
+ async function logActivity(collections, storeRaw, record) {
211
+ await collections.ap_activities.insertOne({
212
+ ...record,
213
+ receivedAt: new Date().toISOString(),
214
+ });
215
+ }