@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,121 @@
1
+ /**
2
+ * Migration controller — handles Mastodon account migration UI.
3
+ *
4
+ * GET: shows the 3-step migration page
5
+ * POST: processes alias update or CSV file import
6
+ */
7
+
8
+ import {
9
+ parseMastodonFollowingCsv,
10
+ parseMastodonFollowersList,
11
+ bulkImportFollowing,
12
+ bulkImportFollowers,
13
+ } from "../migration.js";
14
+
15
+ export function migrateGetController(mountPath) {
16
+ return async (request, response, next) => {
17
+ try {
18
+ response.render("migrate", {
19
+ title: response.locals.__("activitypub.migrate"),
20
+ mountPath,
21
+ result: null,
22
+ });
23
+ } catch (error) {
24
+ next(error);
25
+ }
26
+ };
27
+ }
28
+
29
+ export function migratePostController(mountPath, pluginOptions) {
30
+ return async (request, response, next) => {
31
+ try {
32
+ const { application } = request.app.locals;
33
+ const action = request.body.action;
34
+ let result = null;
35
+
36
+ if (action === "alias") {
37
+ // Update alsoKnownAs on the actor config
38
+ const aliasUrl = request.body.aliasUrl?.trim();
39
+ if (aliasUrl) {
40
+ pluginOptions.alsoKnownAs = aliasUrl;
41
+ result = {
42
+ type: "success",
43
+ text: response.locals.__("activitypub.migrate.aliasSuccess"),
44
+ };
45
+ }
46
+ }
47
+
48
+ if (action === "import") {
49
+ const followingCollection =
50
+ application?.collections?.get("ap_following");
51
+ const followersCollection =
52
+ application?.collections?.get("ap_followers");
53
+
54
+ const importFollowing = request.body.importTypes?.includes("following");
55
+ const importFollowers = request.body.importTypes?.includes("followers");
56
+
57
+ // Read uploaded file — express-fileupload or raw body
58
+ const fileContent = extractFileContent(request);
59
+ if (!fileContent) {
60
+ result = { type: "error", text: "No file uploaded" };
61
+ } else {
62
+ let followingResult = { imported: 0, failed: 0 };
63
+ let followersResult = { imported: 0, failed: 0 };
64
+
65
+ if (importFollowing && followingCollection) {
66
+ const handles = parseMastodonFollowingCsv(fileContent);
67
+ followingResult = await bulkImportFollowing(
68
+ handles,
69
+ followingCollection,
70
+ );
71
+ }
72
+
73
+ if (importFollowers && followersCollection) {
74
+ const entries = parseMastodonFollowersList(fileContent);
75
+ followersResult = await bulkImportFollowers(
76
+ entries,
77
+ followersCollection,
78
+ );
79
+ }
80
+
81
+ const totalFailed =
82
+ followingResult.failed + followersResult.failed;
83
+ result = {
84
+ type: "success",
85
+ text: response.locals
86
+ .__("activitypub.migrate.success")
87
+ .replace("%d", followingResult.imported)
88
+ .replace("%d", followersResult.imported)
89
+ .replace("%d", totalFailed),
90
+ };
91
+ }
92
+ }
93
+
94
+ response.render("migrate", {
95
+ title: response.locals.__("activitypub.migrate"),
96
+ mountPath,
97
+ result,
98
+ });
99
+ } catch (error) {
100
+ next(error);
101
+ }
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Extract file content from the request.
107
+ * Supports express-fileupload (request.files) and raw text body.
108
+ */
109
+ function extractFileContent(request) {
110
+ // express-fileupload attaches to request.files
111
+ if (request.files?.csvFile) {
112
+ return request.files.csvFile.data.toString("utf-8");
113
+ }
114
+
115
+ // Fallback: file content submitted as text in a textarea
116
+ if (request.body.csvContent) {
117
+ return request.body.csvContent;
118
+ }
119
+
120
+ return null;
121
+ }
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Federation handler — the core glue for ActivityPub protocol operations.
3
+ *
4
+ * Handles HTTP Signature signing/verification, inbox dispatch, outbox
5
+ * serving, collection endpoints, and outbound activity delivery.
6
+ *
7
+ * Uses Node's crypto for HTTP Signatures rather than Fedify's middleware,
8
+ * because the plugin owns its own Express routes and Fedify's
9
+ * integrateFederation() expects to own the request lifecycle.
10
+ * Fedify is used for utility functions (e.g. lookupWebFinger in migration).
11
+ */
12
+
13
+ import { createHash, createSign, createVerify } from "node:crypto";
14
+ import { getOrCreateKeyPair } from "./keys.js";
15
+ import { jf2ToActivityStreams, resolvePostUrl } from "./jf2-to-as2.js";
16
+ import { processInboxActivity } from "./inbox.js";
17
+
18
+ /**
19
+ * Create the federation handler used by all AP route handlers in index.js.
20
+ *
21
+ * @param {object} options
22
+ * @param {string} options.actorUrl - Actor URL (e.g. "https://rmendes.net/")
23
+ * @param {string} options.publicationUrl - Publication base URL with trailing slash
24
+ * @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
25
+ * @param {object} options.actorConfig - { handle, name, summary, icon }
26
+ * @param {string} options.alsoKnownAs - Previous account URL for migration
27
+ * @param {object} options.collections - MongoDB collections
28
+ * @returns {object} Handler with handleInbox, handleOutbox, handleFollowers, handleFollowing, deliverToFollowers
29
+ */
30
+ export function createFederationHandler(options) {
31
+ const {
32
+ actorUrl,
33
+ publicationUrl,
34
+ mountPath,
35
+ collections,
36
+ storeRawActivities = false,
37
+ } = options;
38
+
39
+ const baseUrl = publicationUrl.replace(/\/$/, "");
40
+ const keyId = `${actorUrl}#main-key`;
41
+
42
+ // Lazy-loaded key pair — fetched from MongoDB on first use
43
+ let _keyPair = null;
44
+ async function getKeyPair() {
45
+ if (!_keyPair) {
46
+ _keyPair = await getOrCreateKeyPair(collections.ap_keys, actorUrl);
47
+ }
48
+ return _keyPair;
49
+ }
50
+
51
+ return {
52
+ /**
53
+ * POST /inbox — receive and process incoming activities.
54
+ */
55
+ async handleInbox(request, response) {
56
+ let body;
57
+ try {
58
+ const raw =
59
+ request.body instanceof Buffer
60
+ ? request.body
61
+ : Buffer.from(request.body || "");
62
+ body = JSON.parse(raw.toString("utf-8"));
63
+ } catch {
64
+ return response.status(400).json({ error: "Invalid JSON" });
65
+ }
66
+
67
+ // Verify HTTP Signature
68
+ const rawBuffer =
69
+ request.body instanceof Buffer
70
+ ? request.body
71
+ : Buffer.from(request.body || "");
72
+ const signatureValid = await verifyHttpSignature(request, rawBuffer);
73
+ if (!signatureValid) {
74
+ return response.status(401).json({ error: "Invalid HTTP signature" });
75
+ }
76
+
77
+ // Dispatch to inbox handlers
78
+ try {
79
+ await processInboxActivity(body, collections, {
80
+ actorUrl,
81
+ storeRawActivities,
82
+ async deliverActivity(activity, inboxUrl) {
83
+ const keyPair = await getKeyPair();
84
+ return sendSignedActivity(
85
+ activity,
86
+ inboxUrl,
87
+ keyPair.privateKeyPem,
88
+ keyId,
89
+ );
90
+ },
91
+ });
92
+ return response.status(202).json({ status: "accepted" });
93
+ } catch (error) {
94
+ console.error("[ActivityPub] Inbox processing error:", error);
95
+ return response
96
+ .status(500)
97
+ .json({ error: "Failed to process activity" });
98
+ }
99
+ },
100
+
101
+ /**
102
+ * GET /outbox — serve published posts as an OrderedCollection.
103
+ */
104
+ async handleOutbox(request, response) {
105
+ const { application } = request.app.locals;
106
+ const postsCollection = application?.collections?.get("posts");
107
+
108
+ if (!postsCollection) {
109
+ response.set("Content-Type", "application/activity+json");
110
+ return response.json(emptyCollection(`${baseUrl}${mountPath}/outbox`));
111
+ }
112
+
113
+ const page = Number.parseInt(request.query.page, 10) || 0;
114
+ const pageSize = 20;
115
+ const totalItems = await postsCollection.countDocuments();
116
+
117
+ const posts = await postsCollection
118
+ .find()
119
+ .sort({ "properties.published": -1 })
120
+ .skip(page * pageSize)
121
+ .limit(pageSize)
122
+ .toArray();
123
+
124
+ const orderedItems = posts.map((post) =>
125
+ jf2ToActivityStreams(post.properties, actorUrl, publicationUrl),
126
+ );
127
+
128
+ response.set("Content-Type", "application/activity+json");
129
+ return response.json({
130
+ "@context": "https://www.w3.org/ns/activitystreams",
131
+ type: "OrderedCollection",
132
+ id: `${baseUrl}${mountPath}/outbox`,
133
+ totalItems,
134
+ orderedItems,
135
+ });
136
+ },
137
+
138
+ /**
139
+ * GET /followers — serve followers as an OrderedCollection.
140
+ */
141
+ async handleFollowers(request, response) {
142
+ const docs = await collections.ap_followers
143
+ .find()
144
+ .sort({ followedAt: -1 })
145
+ .toArray();
146
+
147
+ response.set("Content-Type", "application/activity+json");
148
+ return response.json({
149
+ "@context": "https://www.w3.org/ns/activitystreams",
150
+ type: "OrderedCollection",
151
+ id: `${baseUrl}${mountPath}/followers`,
152
+ totalItems: docs.length,
153
+ orderedItems: docs.map((f) => f.actorUrl),
154
+ });
155
+ },
156
+
157
+ /**
158
+ * GET /following — serve following as an OrderedCollection.
159
+ */
160
+ async handleFollowing(request, response) {
161
+ const docs = await collections.ap_following
162
+ .find()
163
+ .sort({ followedAt: -1 })
164
+ .toArray();
165
+
166
+ response.set("Content-Type", "application/activity+json");
167
+ return response.json({
168
+ "@context": "https://www.w3.org/ns/activitystreams",
169
+ type: "OrderedCollection",
170
+ id: `${baseUrl}${mountPath}/following`,
171
+ totalItems: docs.length,
172
+ orderedItems: docs.map((f) => f.actorUrl),
173
+ });
174
+ },
175
+
176
+ /**
177
+ * Deliver a post to all followers' inboxes.
178
+ * Called by the syndicator when a post is published with AP ticked.
179
+ *
180
+ * @param {object} properties - JF2 post properties
181
+ * @param {object} publication - Indiekit publication object
182
+ * @returns {string} The ActivityPub object URL (stored as syndication URL)
183
+ */
184
+ async deliverToFollowers(properties) {
185
+ const keyPair = await getKeyPair();
186
+
187
+ const activity = jf2ToActivityStreams(
188
+ properties,
189
+ actorUrl,
190
+ publicationUrl,
191
+ );
192
+
193
+ // Set an explicit activity ID
194
+ const postUrl = resolvePostUrl(properties.url, publicationUrl);
195
+ activity.id = `${postUrl}#activity`;
196
+
197
+ // Gather unique inbox URLs (prefer sharedInbox for efficiency)
198
+ const followers = await collections.ap_followers.find().toArray();
199
+ const inboxes = new Set();
200
+ for (const follower of followers) {
201
+ inboxes.add(follower.sharedInbox || follower.inbox);
202
+ }
203
+
204
+ // Deliver to each unique inbox
205
+ let delivered = 0;
206
+ for (const inboxUrl of inboxes) {
207
+ if (!inboxUrl) continue;
208
+ const ok = await sendSignedActivity(
209
+ activity,
210
+ inboxUrl,
211
+ keyPair.privateKeyPem,
212
+ keyId,
213
+ );
214
+ if (ok) delivered++;
215
+ }
216
+
217
+ // Log outbound activity
218
+ await collections.ap_activities.insertOne({
219
+ direction: "outbound",
220
+ type: activity.type,
221
+ actorUrl,
222
+ objectUrl: activity.object?.id || activity.object,
223
+ summary: `Delivered ${activity.type} to ${delivered}/${inboxes.size} inboxes`,
224
+ receivedAt: new Date(),
225
+ ...(storeRawActivities ? { raw: activity } : {}),
226
+ });
227
+
228
+ // Return the object URL — Indiekit stores this in the post's syndication array
229
+ return activity.object?.id || activity.object?.url || postUrl;
230
+ },
231
+ };
232
+ }
233
+
234
+ // --- HTTP Signature implementation ---
235
+
236
+ /**
237
+ * Compute SHA-256 digest of a body buffer for the Digest header.
238
+ */
239
+ function computeDigest(body) {
240
+ const hash = createHash("sha256").update(body).digest("base64");
241
+ return `SHA-256=${hash}`;
242
+ }
243
+
244
+ /**
245
+ * Sign and send an activity to a remote inbox.
246
+ *
247
+ * @param {object} activity - ActivityStreams activity object
248
+ * @param {string} inboxUrl - Target inbox URL
249
+ * @param {string} privateKeyPem - PEM-encoded RSA private key
250
+ * @param {string} keyId - Key ID URL (e.g. "https://rmendes.net/#main-key")
251
+ * @returns {Promise<boolean>} true if delivery succeeded
252
+ */
253
+ async function sendSignedActivity(activity, inboxUrl, privateKeyPem, keyId) {
254
+ const body = JSON.stringify(activity);
255
+ const bodyBuffer = Buffer.from(body);
256
+ const url = new URL(inboxUrl);
257
+ const date = new Date().toUTCString();
258
+ const digest = computeDigest(bodyBuffer);
259
+
260
+ // Build the signing string per HTTP Signatures spec
261
+ const signingString = [
262
+ `(request-target): post ${url.pathname}`,
263
+ `host: ${url.host}`,
264
+ `date: ${date}`,
265
+ `digest: ${digest}`,
266
+ ].join("\n");
267
+
268
+ const signer = createSign("sha256");
269
+ signer.update(signingString);
270
+ const signature = signer.sign(privateKeyPem, "base64");
271
+
272
+ const signatureHeader = [
273
+ `keyId="${keyId}"`,
274
+ `algorithm="rsa-sha256"`,
275
+ `headers="(request-target) host date digest"`,
276
+ `signature="${signature}"`,
277
+ ].join(",");
278
+
279
+ try {
280
+ const response = await fetch(inboxUrl, {
281
+ method: "POST",
282
+ headers: {
283
+ "Content-Type": "application/activity+json",
284
+ Host: url.host,
285
+ Date: date,
286
+ Digest: digest,
287
+ Signature: signatureHeader,
288
+ },
289
+ body,
290
+ signal: AbortSignal.timeout(15_000),
291
+ });
292
+ return response.ok || response.status === 202;
293
+ } catch (error) {
294
+ console.error(
295
+ `[ActivityPub] Delivery to ${inboxUrl} failed:`,
296
+ error.message,
297
+ );
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Verify the HTTP Signature on an incoming request.
304
+ *
305
+ * 1. Parse the Signature header
306
+ * 2. Fetch the remote actor's public key via keyId
307
+ * 3. Reconstruct the signing string
308
+ * 4. Verify with RSA-SHA256
309
+ *
310
+ * @param {object} request - Express request object
311
+ * @param {Buffer} rawBody - Raw request body for digest verification
312
+ * @returns {Promise<boolean>} true if signature is valid
313
+ */
314
+ async function verifyHttpSignature(request, rawBody) {
315
+ const sigHeader = request.headers.signature;
316
+ if (!sigHeader) return false;
317
+
318
+ // Parse signature header: keyId="...",algorithm="...",headers="...",signature="..."
319
+ const params = {};
320
+ for (const part of sigHeader.split(",")) {
321
+ const eqIndex = part.indexOf("=");
322
+ if (eqIndex === -1) continue;
323
+ const key = part.slice(0, eqIndex).trim();
324
+ const value = part.slice(eqIndex + 1).trim().replace(/^"|"$/g, "");
325
+ params[key] = value;
326
+ }
327
+
328
+ const { keyId: remoteKeyId, headers: headerNames, signature } = params;
329
+ if (!remoteKeyId || !headerNames || !signature) return false;
330
+
331
+ // Verify Digest header matches body
332
+ if (request.headers.digest) {
333
+ const expectedDigest = computeDigest(rawBody);
334
+ if (request.headers.digest !== expectedDigest) return false;
335
+ }
336
+
337
+ // Fetch the remote actor document to get their public key
338
+ const publicKeyPem = await fetchRemotePublicKey(remoteKeyId);
339
+ if (!publicKeyPem) return false;
340
+
341
+ // Reconstruct signing string from the listed headers
342
+ const headerList = headerNames.split(" ");
343
+ const signingParts = headerList.map((h) => {
344
+ if (h === "(request-target)") {
345
+ const method = request.method.toLowerCase();
346
+ const path = request.originalUrl || request.url;
347
+ return `(request-target): ${method} ${path}`;
348
+ }
349
+ if (h === "host") {
350
+ return `host: ${request.headers.host || request.hostname}`;
351
+ }
352
+ return `${h}: ${request.headers[h]}`;
353
+ });
354
+ const signingString = signingParts.join("\n");
355
+
356
+ // Verify
357
+ try {
358
+ const verifier = createVerify("sha256");
359
+ verifier.update(signingString);
360
+ return verifier.verify(publicKeyPem, signature, "base64");
361
+ } catch {
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Fetch a remote actor's public key by key ID URL.
368
+ * The keyId is typically "https://remote.example/users/alice#main-key"
369
+ * — we fetch the actor document (without fragment) and extract publicKey.
370
+ */
371
+ async function fetchRemotePublicKey(keyIdUrl) {
372
+ try {
373
+ // Remove fragment to get the actor document URL
374
+ const actorUrl = keyIdUrl.split("#")[0];
375
+
376
+ const response = await fetch(actorUrl, {
377
+ headers: { Accept: "application/activity+json" },
378
+ signal: AbortSignal.timeout(10_000),
379
+ });
380
+
381
+ if (!response.ok) return null;
382
+
383
+ const doc = await response.json();
384
+
385
+ // Key may be at doc.publicKey.publicKeyPem or in a publicKey array
386
+ if (doc.publicKey) {
387
+ const key = Array.isArray(doc.publicKey)
388
+ ? doc.publicKey.find((k) => k.id === keyIdUrl) || doc.publicKey[0]
389
+ : doc.publicKey;
390
+ return key?.publicKeyPem || null;
391
+ }
392
+
393
+ return null;
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Build an empty OrderedCollection response.
401
+ */
402
+ function emptyCollection(id) {
403
+ return {
404
+ "@context": "https://www.w3.org/ns/activitystreams",
405
+ type: "OrderedCollection",
406
+ id,
407
+ totalItems: 0,
408
+ orderedItems: [],
409
+ };
410
+ }