@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.
- package/assets/icon.svg +12 -0
- package/index.js +376 -0
- package/lib/actor.js +75 -0
- package/lib/controllers/activities.js +56 -0
- package/lib/controllers/dashboard.js +39 -0
- package/lib/controllers/followers.js +58 -0
- package/lib/controllers/following.js +58 -0
- package/lib/controllers/migrate.js +121 -0
- package/lib/federation.js +410 -0
- package/lib/inbox.js +291 -0
- package/lib/jf2-to-as2.js +191 -0
- package/lib/keys.js +39 -0
- package/lib/migration.js +184 -0
- package/lib/webfinger.js +43 -0
- package/locales/en.json +41 -0
- package/package.json +51 -0
- package/views/activities.njk +29 -0
- package/views/dashboard.njk +45 -0
- package/views/followers.njk +26 -0
- package/views/following.njk +27 -0
- package/views/migrate.njk +67 -0
|
@@ -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
|
+
}
|