@rmdes/indiekit-endpoint-activitypub 3.13.11 → 3.13.13
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 +21 -394
- package/lib/endpoint-federation.js +407 -0
- package/lib/routes/public-routes.js +5 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -4,27 +4,30 @@ import { ACTIVITYPUB_BLOCKS } from "./lib/blocks.js";
|
|
|
4
4
|
import { resolveOptions } from "./lib/defaults.js";
|
|
5
5
|
import { buildNavigationItems } from "./lib/navigation.js";
|
|
6
6
|
|
|
7
|
-
import { setupFederation
|
|
7
|
+
import { setupFederation } from "./lib/federation-setup.js";
|
|
8
8
|
import { createMastodonRouter } from "./lib/mastodon/router.js";
|
|
9
9
|
import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
|
|
10
10
|
import { initRedisCache } from "./lib/redis-cache.js";
|
|
11
11
|
import { createIndexes } from "./lib/init-indexes.js";
|
|
12
12
|
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
|
|
13
|
-
import {
|
|
14
|
-
needsDirectFollow,
|
|
15
|
-
sendDirectFollow,
|
|
16
|
-
sendDirectUnfollow,
|
|
17
|
-
} from "./lib/direct-follow.js";
|
|
18
13
|
import {
|
|
19
14
|
createFedifyMiddleware,
|
|
20
15
|
} from "./lib/federation-bridge.js";
|
|
21
|
-
import { jf2ToAS2Activity } from "./lib/jf2-to-as2.js";
|
|
22
16
|
import { createSyndicator } from "./lib/syndicator.js";
|
|
17
|
+
import {
|
|
18
|
+
loadRsaPrivateKey,
|
|
19
|
+
followActor,
|
|
20
|
+
unfollowActor,
|
|
21
|
+
broadcastActorUpdate,
|
|
22
|
+
broadcastDelete,
|
|
23
|
+
broadcastPostUpdate,
|
|
24
|
+
deletePost,
|
|
25
|
+
updatePost,
|
|
26
|
+
getActorUrl,
|
|
27
|
+
} from "./lib/endpoint-federation.js";
|
|
23
28
|
import { buildAdminRoutes } from "./lib/routes/admin-routes.js";
|
|
24
29
|
import { buildRoutesPublic, buildContentNegotiationRoutes } from "./lib/routes/public-routes.js";
|
|
25
30
|
import { startBatchRefollow } from "./lib/batch-refollow.js";
|
|
26
|
-
import { logActivity } from "./lib/activity-log.js";
|
|
27
|
-
import { batchBroadcast } from "./lib/batch-broadcast.js";
|
|
28
31
|
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
|
29
32
|
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
|
|
30
33
|
import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
|
|
@@ -113,141 +116,11 @@ export default class ActivityPubEndpoint {
|
|
|
113
116
|
* @returns {Promise<CryptoKey|null>}
|
|
114
117
|
*/
|
|
115
118
|
async _loadRsaPrivateKey() {
|
|
116
|
-
|
|
117
|
-
const keyDoc = await this._collections.ap_keys.findOne({
|
|
118
|
-
privateKeyPem: { $exists: true },
|
|
119
|
-
});
|
|
120
|
-
if (!keyDoc?.privateKeyPem) return null;
|
|
121
|
-
const pemBody = keyDoc.privateKeyPem
|
|
122
|
-
.replace(/-----[^-]+-----/g, "")
|
|
123
|
-
.replace(/\s/g, "");
|
|
124
|
-
return await crypto.subtle.importKey(
|
|
125
|
-
"pkcs8",
|
|
126
|
-
Buffer.from(pemBody, "base64"),
|
|
127
|
-
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
128
|
-
true,
|
|
129
|
-
["sign"],
|
|
130
|
-
);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
console.error("[ActivityPub] Failed to load RSA key:", error.message);
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
119
|
+
return loadRsaPrivateKey(this);
|
|
135
120
|
}
|
|
136
121
|
|
|
137
122
|
async followActor(actorUrl, actorInfo = {}) {
|
|
138
|
-
|
|
139
|
-
return { ok: false, error: "Federation not initialized" };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
const { Follow } = await import("@fedify/fedify/vocab");
|
|
144
|
-
const handle = this.options.actor.handle;
|
|
145
|
-
const ctx = this._federation.createContext(
|
|
146
|
-
new URL(this._publicationUrl),
|
|
147
|
-
{ handle, publicationUrl: this._publicationUrl },
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
// Resolve the remote actor to get their inbox
|
|
151
|
-
// lookupWithSecurity handles signed→unsigned fallback automatically
|
|
152
|
-
const documentLoader = await ctx.getDocumentLoader({
|
|
153
|
-
identifier: handle,
|
|
154
|
-
});
|
|
155
|
-
const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
|
|
156
|
-
documentLoader,
|
|
157
|
-
});
|
|
158
|
-
if (!remoteActor) {
|
|
159
|
-
return { ok: false, error: "Could not resolve remote actor" };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Send Follow activity
|
|
163
|
-
if (needsDirectFollow(actorUrl)) {
|
|
164
|
-
// tags.pub rejects Fedify's LD Signature context (identity/v1).
|
|
165
|
-
// Send a minimal signed Follow directly, bypassing the outbox pipeline.
|
|
166
|
-
// See: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
167
|
-
const rsaKey = await this._loadRsaPrivateKey();
|
|
168
|
-
if (!rsaKey) {
|
|
169
|
-
return { ok: false, error: "No RSA key available for direct follow" };
|
|
170
|
-
}
|
|
171
|
-
const result = await sendDirectFollow({
|
|
172
|
-
actorUri: ctx.getActorUri(handle).href,
|
|
173
|
-
targetActorUrl: actorUrl,
|
|
174
|
-
inboxUrl: remoteActor.inboxId?.href,
|
|
175
|
-
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
176
|
-
privateKey: rsaKey,
|
|
177
|
-
});
|
|
178
|
-
if (!result.ok) {
|
|
179
|
-
return { ok: false, error: result.error };
|
|
180
|
-
}
|
|
181
|
-
} else {
|
|
182
|
-
const follow = new Follow({
|
|
183
|
-
actor: ctx.getActorUri(handle),
|
|
184
|
-
object: new URL(actorUrl),
|
|
185
|
-
});
|
|
186
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
187
|
-
orderingKey: actorUrl,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Store in ap_following
|
|
192
|
-
const name =
|
|
193
|
-
actorInfo.name ||
|
|
194
|
-
remoteActor.name?.toString() ||
|
|
195
|
-
remoteActor.preferredUsername?.toString() ||
|
|
196
|
-
actorUrl;
|
|
197
|
-
const actorHandle =
|
|
198
|
-
actorInfo.handle ||
|
|
199
|
-
remoteActor.preferredUsername?.toString() ||
|
|
200
|
-
"";
|
|
201
|
-
const avatar =
|
|
202
|
-
actorInfo.photo ||
|
|
203
|
-
(remoteActor.icon
|
|
204
|
-
? (await remoteActor.icon)?.url?.href || ""
|
|
205
|
-
: "");
|
|
206
|
-
const inbox = remoteActor.inboxId?.href || "";
|
|
207
|
-
const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
|
|
208
|
-
|
|
209
|
-
await this._collections.ap_following.updateOne(
|
|
210
|
-
{ actorUrl },
|
|
211
|
-
{
|
|
212
|
-
$set: {
|
|
213
|
-
actorUrl,
|
|
214
|
-
handle: actorHandle,
|
|
215
|
-
name,
|
|
216
|
-
avatar,
|
|
217
|
-
inbox,
|
|
218
|
-
sharedInbox,
|
|
219
|
-
followedAt: new Date().toISOString(),
|
|
220
|
-
source: "reader",
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
{ upsert: true },
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
console.info(`[ActivityPub] Sent Follow to ${actorUrl}`);
|
|
227
|
-
|
|
228
|
-
await logActivity(this._collections.ap_activities, {
|
|
229
|
-
direction: "outbound",
|
|
230
|
-
type: "Follow",
|
|
231
|
-
actorUrl: this._publicationUrl,
|
|
232
|
-
objectUrl: actorUrl,
|
|
233
|
-
actorName: name,
|
|
234
|
-
summary: `Sent Follow to ${name} (${actorUrl})`,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
return { ok: true };
|
|
238
|
-
} catch (error) {
|
|
239
|
-
console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message);
|
|
240
|
-
|
|
241
|
-
await logActivity(this._collections.ap_activities, {
|
|
242
|
-
direction: "outbound",
|
|
243
|
-
type: "Follow",
|
|
244
|
-
actorUrl: this._publicationUrl,
|
|
245
|
-
objectUrl: actorUrl,
|
|
246
|
-
summary: `Follow failed for ${actorUrl}: ${error.message}`,
|
|
247
|
-
}).catch(() => {});
|
|
248
|
-
|
|
249
|
-
return { ok: false, error: error.message };
|
|
250
|
-
}
|
|
123
|
+
return followActor(this, actorUrl, actorInfo);
|
|
251
124
|
}
|
|
252
125
|
|
|
253
126
|
/**
|
|
@@ -256,277 +129,31 @@ export default class ActivityPubEndpoint {
|
|
|
256
129
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
257
130
|
*/
|
|
258
131
|
async unfollowActor(actorUrl) {
|
|
259
|
-
|
|
260
|
-
return { ok: false, error: "Federation not initialized" };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
const { Follow, Undo } = await import("@fedify/fedify/vocab");
|
|
265
|
-
const handle = this.options.actor.handle;
|
|
266
|
-
const ctx = this._federation.createContext(
|
|
267
|
-
new URL(this._publicationUrl),
|
|
268
|
-
{ handle, publicationUrl: this._publicationUrl },
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
// Use authenticated document loader for servers requiring Authorized Fetch
|
|
272
|
-
const documentLoader = await ctx.getDocumentLoader({
|
|
273
|
-
identifier: handle,
|
|
274
|
-
});
|
|
275
|
-
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
|
|
276
|
-
documentLoader,
|
|
277
|
-
});
|
|
278
|
-
if (!remoteActor) {
|
|
279
|
-
// Even if we can't resolve, remove locally
|
|
280
|
-
await this._collections.ap_following.deleteOne({ actorUrl });
|
|
281
|
-
|
|
282
|
-
await logActivity(this._collections.ap_activities, {
|
|
283
|
-
direction: "outbound",
|
|
284
|
-
type: "Undo(Follow)",
|
|
285
|
-
actorUrl: this._publicationUrl,
|
|
286
|
-
objectUrl: actorUrl,
|
|
287
|
-
summary: `Removed ${actorUrl} locally (could not resolve remote actor)`,
|
|
288
|
-
}).catch(() => {});
|
|
289
|
-
|
|
290
|
-
return { ok: true };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (needsDirectFollow(actorUrl)) {
|
|
294
|
-
// tags.pub rejects Fedify's LD Signature context (identity/v1).
|
|
295
|
-
// See: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
296
|
-
const rsaKey = await this._loadRsaPrivateKey();
|
|
297
|
-
if (rsaKey) {
|
|
298
|
-
const result = await sendDirectUnfollow({
|
|
299
|
-
actorUri: ctx.getActorUri(handle).href,
|
|
300
|
-
targetActorUrl: actorUrl,
|
|
301
|
-
inboxUrl: remoteActor.inboxId?.href,
|
|
302
|
-
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
303
|
-
privateKey: rsaKey,
|
|
304
|
-
});
|
|
305
|
-
if (!result.ok) {
|
|
306
|
-
console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
} else {
|
|
310
|
-
const follow = new Follow({
|
|
311
|
-
actor: ctx.getActorUri(handle),
|
|
312
|
-
object: new URL(actorUrl),
|
|
313
|
-
});
|
|
314
|
-
const undo = new Undo({
|
|
315
|
-
actor: ctx.getActorUri(handle),
|
|
316
|
-
object: follow,
|
|
317
|
-
});
|
|
318
|
-
await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
|
|
319
|
-
orderingKey: actorUrl,
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
await this._collections.ap_following.deleteOne({ actorUrl });
|
|
323
|
-
|
|
324
|
-
console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
|
|
325
|
-
|
|
326
|
-
await logActivity(this._collections.ap_activities, {
|
|
327
|
-
direction: "outbound",
|
|
328
|
-
type: "Undo(Follow)",
|
|
329
|
-
actorUrl: this._publicationUrl,
|
|
330
|
-
objectUrl: actorUrl,
|
|
331
|
-
summary: `Sent Undo(Follow) to ${actorUrl}`,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
return { ok: true };
|
|
335
|
-
} catch (error) {
|
|
336
|
-
console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message);
|
|
337
|
-
|
|
338
|
-
await logActivity(this._collections.ap_activities, {
|
|
339
|
-
direction: "outbound",
|
|
340
|
-
type: "Undo(Follow)",
|
|
341
|
-
actorUrl: this._publicationUrl,
|
|
342
|
-
objectUrl: actorUrl,
|
|
343
|
-
summary: `Unfollow failed for ${actorUrl}: ${error.message}`,
|
|
344
|
-
}).catch(() => {});
|
|
345
|
-
|
|
346
|
-
// Remove locally even if remote delivery fails
|
|
347
|
-
await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});
|
|
348
|
-
return { ok: false, error: error.message };
|
|
349
|
-
}
|
|
132
|
+
return unfollowActor(this, actorUrl);
|
|
350
133
|
}
|
|
351
134
|
|
|
352
|
-
/**
|
|
353
|
-
* Send an Update(Person) activity to all followers so remote servers
|
|
354
|
-
* re-fetch the actor object (picking up profile changes, new featured
|
|
355
|
-
* collections, attachments, etc.).
|
|
356
|
-
*
|
|
357
|
-
* Delivery is batched to avoid a thundering herd: hundreds of remote
|
|
358
|
-
* servers simultaneously re-fetching the actor, featured posts, and
|
|
359
|
-
* featured tags after receiving the Update all at once.
|
|
360
|
-
*/
|
|
361
135
|
async broadcastActorUpdate() {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
try {
|
|
365
|
-
const { Update } = await import("@fedify/fedify/vocab");
|
|
366
|
-
const handle = this.options.actor.handle;
|
|
367
|
-
const ctx = this._federation.createContext(
|
|
368
|
-
new URL(this._publicationUrl),
|
|
369
|
-
{ handle, publicationUrl: this._publicationUrl },
|
|
370
|
-
);
|
|
371
|
-
|
|
372
|
-
const actor = await buildPersonActor(
|
|
373
|
-
ctx,
|
|
374
|
-
handle,
|
|
375
|
-
this._collections,
|
|
376
|
-
this.options.actorType,
|
|
377
|
-
);
|
|
378
|
-
if (!actor) {
|
|
379
|
-
console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const update = new Update({
|
|
384
|
-
actor: ctx.getActorUri(handle),
|
|
385
|
-
object: actor,
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
await batchBroadcast({
|
|
389
|
-
federation: this._federation,
|
|
390
|
-
collections: this._collections,
|
|
391
|
-
publicationUrl: this._publicationUrl,
|
|
392
|
-
handle,
|
|
393
|
-
activity: update,
|
|
394
|
-
label: "Update(Person)",
|
|
395
|
-
objectUrl: this._getActorUrl(),
|
|
396
|
-
});
|
|
397
|
-
} catch (error) {
|
|
398
|
-
console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
|
|
399
|
-
}
|
|
136
|
+
return broadcastActorUpdate(this);
|
|
400
137
|
}
|
|
401
138
|
|
|
402
|
-
/**
|
|
403
|
-
* Send Delete activity to all followers for a removed post.
|
|
404
|
-
* Mirrors broadcastActorUpdate() pattern: batch delivery with shared inbox dedup.
|
|
405
|
-
* @param {string} postUrl - Full URL of the deleted post
|
|
406
|
-
*/
|
|
407
139
|
async broadcastDelete(postUrl) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
try {
|
|
411
|
-
const { Delete } = await import("@fedify/fedify/vocab");
|
|
412
|
-
const handle = this.options.actor.handle;
|
|
413
|
-
const ctx = this._federation.createContext(
|
|
414
|
-
new URL(this._publicationUrl),
|
|
415
|
-
{ handle, publicationUrl: this._publicationUrl },
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const del = new Delete({
|
|
419
|
-
actor: ctx.getActorUri(handle),
|
|
420
|
-
object: new URL(postUrl),
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
await batchBroadcast({
|
|
424
|
-
federation: this._federation,
|
|
425
|
-
collections: this._collections,
|
|
426
|
-
publicationUrl: this._publicationUrl,
|
|
427
|
-
handle,
|
|
428
|
-
activity: del,
|
|
429
|
-
label: "Delete",
|
|
430
|
-
objectUrl: postUrl,
|
|
431
|
-
});
|
|
432
|
-
} catch (error) {
|
|
433
|
-
console.warn("[ActivityPub] broadcastDelete failed:", error.message);
|
|
434
|
-
}
|
|
140
|
+
return broadcastDelete(this, postUrl);
|
|
435
141
|
}
|
|
436
142
|
|
|
437
|
-
/**
|
|
438
|
-
* Called by post-content.js when a Micropub delete succeeds.
|
|
439
|
-
* Broadcasts an ActivityPub Delete activity to all followers.
|
|
440
|
-
* @param {string} url - Full URL of the deleted post
|
|
441
|
-
*/
|
|
442
143
|
async delete(url) {
|
|
443
|
-
|
|
444
|
-
try {
|
|
445
|
-
const { addTombstone } = await import("./lib/storage/tombstones.js");
|
|
446
|
-
const postsCol = this._collections.posts;
|
|
447
|
-
const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
|
|
448
|
-
await addTombstone(this._collections, {
|
|
449
|
-
url,
|
|
450
|
-
formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
|
|
451
|
-
published: post?.properties?.published || null,
|
|
452
|
-
deleted: new Date().toISOString(),
|
|
453
|
-
});
|
|
454
|
-
} catch (error) {
|
|
455
|
-
console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
await this.broadcastDelete(url).catch((err) =>
|
|
459
|
-
console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
|
|
460
|
-
);
|
|
144
|
+
return deletePost(this, url);
|
|
461
145
|
}
|
|
462
146
|
|
|
463
|
-
/**
|
|
464
|
-
* Called by post-content.js when a Micropub update succeeds.
|
|
465
|
-
* Broadcasts an ActivityPub Update activity for the post to all followers.
|
|
466
|
-
* @param {object} properties - JF2 post properties (must include url)
|
|
467
|
-
*/
|
|
468
147
|
async update(properties) {
|
|
469
|
-
|
|
470
|
-
console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
|
|
471
|
-
);
|
|
148
|
+
return updatePost(this, properties);
|
|
472
149
|
}
|
|
473
150
|
|
|
474
|
-
/**
|
|
475
|
-
* Send an Update activity to all followers for a modified post.
|
|
476
|
-
* Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup.
|
|
477
|
-
* @param {object} properties - JF2 post properties
|
|
478
|
-
*/
|
|
479
151
|
async broadcastPostUpdate(properties) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
const { Update } = await import("@fedify/fedify/vocab");
|
|
484
|
-
const actorUrl = this._getActorUrl();
|
|
485
|
-
const handle = this.options.actor.handle;
|
|
486
|
-
const ctx = this._federation.createContext(
|
|
487
|
-
new URL(this._publicationUrl),
|
|
488
|
-
{ handle, publicationUrl: this._publicationUrl },
|
|
489
|
-
);
|
|
490
|
-
|
|
491
|
-
const createActivity = jf2ToAS2Activity(
|
|
492
|
-
properties,
|
|
493
|
-
actorUrl,
|
|
494
|
-
this._publicationUrl,
|
|
495
|
-
{ visibility: this.options.defaultVisibility },
|
|
496
|
-
);
|
|
497
|
-
|
|
498
|
-
if (!createActivity) {
|
|
499
|
-
console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const noteObject = await createActivity.getObject();
|
|
504
|
-
const activity = new Update({
|
|
505
|
-
actor: ctx.getActorUri(handle),
|
|
506
|
-
object: noteObject,
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
await batchBroadcast({
|
|
510
|
-
federation: this._federation,
|
|
511
|
-
collections: this._collections,
|
|
512
|
-
publicationUrl: this._publicationUrl,
|
|
513
|
-
handle,
|
|
514
|
-
activity,
|
|
515
|
-
label: "Update(Note)",
|
|
516
|
-
objectUrl: properties.url,
|
|
517
|
-
});
|
|
518
|
-
} catch (error) {
|
|
519
|
-
console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
|
|
520
|
-
}
|
|
152
|
+
return broadcastPostUpdate(this, properties);
|
|
521
153
|
}
|
|
522
154
|
|
|
523
|
-
/**
|
|
524
|
-
* Build the full actor URL from config.
|
|
525
|
-
* @returns {string}
|
|
526
|
-
*/
|
|
527
155
|
_getActorUrl() {
|
|
528
|
-
|
|
529
|
-
return `${base}${this.options.mountPath}/users/${this.options.actor.handle}`;
|
|
156
|
+
return getActorUrl(this);
|
|
530
157
|
}
|
|
531
158
|
|
|
532
159
|
init(Indiekit) {
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation send-path actions, extracted from the index.js god-entry
|
|
3
|
+
* (Phase 2). Each takes `self` (the ActivityPubEndpoint instance); the class
|
|
4
|
+
* keeps thin delegating methods so the public interface + the init() facade
|
|
5
|
+
* are preserved. Internal cross-calls go directly to the module functions.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
needsDirectFollow,
|
|
9
|
+
sendDirectFollow,
|
|
10
|
+
sendDirectUnfollow,
|
|
11
|
+
} from "./direct-follow.js";
|
|
12
|
+
import { lookupWithSecurity } from "./lookup-helpers.js";
|
|
13
|
+
import { logActivity } from "./activity-log.js";
|
|
14
|
+
import { batchBroadcast } from "./batch-broadcast.js";
|
|
15
|
+
import { buildPersonActor } from "./federation-setup.js";
|
|
16
|
+
import { jf2ToAS2Activity } from "./jf2-to-as2.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load the RSA private key from ap_keys for direct HTTP Signature signing.
|
|
20
|
+
* @returns {Promise<CryptoKey|null>}
|
|
21
|
+
*/
|
|
22
|
+
export async function loadRsaPrivateKey(self) {
|
|
23
|
+
try {
|
|
24
|
+
const keyDoc = await self._collections.ap_keys.findOne({
|
|
25
|
+
privateKeyPem: { $exists: true },
|
|
26
|
+
});
|
|
27
|
+
if (!keyDoc?.privateKeyPem) return null;
|
|
28
|
+
const pemBody = keyDoc.privateKeyPem
|
|
29
|
+
.replace(/-----[^-]+-----/g, "")
|
|
30
|
+
.replace(/\s/g, "");
|
|
31
|
+
return await crypto.subtle.importKey(
|
|
32
|
+
"pkcs8",
|
|
33
|
+
Buffer.from(pemBody, "base64"),
|
|
34
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
35
|
+
true,
|
|
36
|
+
["sign"],
|
|
37
|
+
);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error("[ActivityPub] Failed to load RSA key:", error.message);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Send a Follow activity to a remote actor and store in ap_following. */
|
|
45
|
+
export async function followActor(self, actorUrl, actorInfo = {}) {
|
|
46
|
+
if (!self._federation) {
|
|
47
|
+
return { ok: false, error: "Federation not initialized" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { Follow } = await import("@fedify/fedify/vocab");
|
|
52
|
+
const handle = self.options.actor.handle;
|
|
53
|
+
const ctx = self._federation.createContext(
|
|
54
|
+
new URL(self._publicationUrl),
|
|
55
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Resolve the remote actor to get their inbox
|
|
59
|
+
// lookupWithSecurity handles signed→unsigned fallback automatically
|
|
60
|
+
const documentLoader = await ctx.getDocumentLoader({
|
|
61
|
+
identifier: handle,
|
|
62
|
+
});
|
|
63
|
+
const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
|
|
64
|
+
documentLoader,
|
|
65
|
+
});
|
|
66
|
+
if (!remoteActor) {
|
|
67
|
+
return { ok: false, error: "Could not resolve remote actor" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Send Follow activity
|
|
71
|
+
if (needsDirectFollow(actorUrl)) {
|
|
72
|
+
// tags.pub rejects Fedify's LD Signature context (identity/v1).
|
|
73
|
+
// Send a minimal signed Follow directly, bypassing the outbox pipeline.
|
|
74
|
+
// See: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
75
|
+
const rsaKey = await loadRsaPrivateKey(self);
|
|
76
|
+
if (!rsaKey) {
|
|
77
|
+
return { ok: false, error: "No RSA key available for direct follow" };
|
|
78
|
+
}
|
|
79
|
+
const result = await sendDirectFollow({
|
|
80
|
+
actorUri: ctx.getActorUri(handle).href,
|
|
81
|
+
targetActorUrl: actorUrl,
|
|
82
|
+
inboxUrl: remoteActor.inboxId?.href,
|
|
83
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
84
|
+
privateKey: rsaKey,
|
|
85
|
+
});
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
return { ok: false, error: result.error };
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const follow = new Follow({
|
|
91
|
+
actor: ctx.getActorUri(handle),
|
|
92
|
+
object: new URL(actorUrl),
|
|
93
|
+
});
|
|
94
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
|
|
95
|
+
orderingKey: actorUrl,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Store in ap_following
|
|
100
|
+
const name =
|
|
101
|
+
actorInfo.name ||
|
|
102
|
+
remoteActor.name?.toString() ||
|
|
103
|
+
remoteActor.preferredUsername?.toString() ||
|
|
104
|
+
actorUrl;
|
|
105
|
+
const actorHandle =
|
|
106
|
+
actorInfo.handle ||
|
|
107
|
+
remoteActor.preferredUsername?.toString() ||
|
|
108
|
+
"";
|
|
109
|
+
const avatar =
|
|
110
|
+
actorInfo.photo ||
|
|
111
|
+
(remoteActor.icon
|
|
112
|
+
? (await remoteActor.icon)?.url?.href || ""
|
|
113
|
+
: "");
|
|
114
|
+
const inbox = remoteActor.inboxId?.href || "";
|
|
115
|
+
const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
|
|
116
|
+
|
|
117
|
+
await self._collections.ap_following.updateOne(
|
|
118
|
+
{ actorUrl },
|
|
119
|
+
{
|
|
120
|
+
$set: {
|
|
121
|
+
actorUrl,
|
|
122
|
+
handle: actorHandle,
|
|
123
|
+
name,
|
|
124
|
+
avatar,
|
|
125
|
+
inbox,
|
|
126
|
+
sharedInbox,
|
|
127
|
+
followedAt: new Date().toISOString(),
|
|
128
|
+
source: "reader",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{ upsert: true },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
console.info(`[ActivityPub] Sent Follow to ${actorUrl}`);
|
|
135
|
+
|
|
136
|
+
await logActivity(self._collections.ap_activities, {
|
|
137
|
+
direction: "outbound",
|
|
138
|
+
type: "Follow",
|
|
139
|
+
actorUrl: self._publicationUrl,
|
|
140
|
+
objectUrl: actorUrl,
|
|
141
|
+
actorName: name,
|
|
142
|
+
summary: `Sent Follow to ${name} (${actorUrl})`,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return { ok: true };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message);
|
|
148
|
+
|
|
149
|
+
await logActivity(self._collections.ap_activities, {
|
|
150
|
+
direction: "outbound",
|
|
151
|
+
type: "Follow",
|
|
152
|
+
actorUrl: self._publicationUrl,
|
|
153
|
+
objectUrl: actorUrl,
|
|
154
|
+
summary: `Follow failed for ${actorUrl}: ${error.message}`,
|
|
155
|
+
}).catch(() => {});
|
|
156
|
+
|
|
157
|
+
return { ok: false, error: error.message };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Send an Undo(Follow) activity and remove from ap_following. */
|
|
162
|
+
export async function unfollowActor(self, actorUrl) {
|
|
163
|
+
if (!self._federation) {
|
|
164
|
+
return { ok: false, error: "Federation not initialized" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const { Follow, Undo } = await import("@fedify/fedify/vocab");
|
|
169
|
+
const handle = self.options.actor.handle;
|
|
170
|
+
const ctx = self._federation.createContext(
|
|
171
|
+
new URL(self._publicationUrl),
|
|
172
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Use authenticated document loader for servers requiring Authorized Fetch
|
|
176
|
+
const documentLoader = await ctx.getDocumentLoader({
|
|
177
|
+
identifier: handle,
|
|
178
|
+
});
|
|
179
|
+
const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
|
|
180
|
+
documentLoader,
|
|
181
|
+
});
|
|
182
|
+
if (!remoteActor) {
|
|
183
|
+
// Even if we can't resolve, remove locally
|
|
184
|
+
await self._collections.ap_following.deleteOne({ actorUrl });
|
|
185
|
+
|
|
186
|
+
await logActivity(self._collections.ap_activities, {
|
|
187
|
+
direction: "outbound",
|
|
188
|
+
type: "Undo(Follow)",
|
|
189
|
+
actorUrl: self._publicationUrl,
|
|
190
|
+
objectUrl: actorUrl,
|
|
191
|
+
summary: `Removed ${actorUrl} locally (could not resolve remote actor)`,
|
|
192
|
+
}).catch(() => {});
|
|
193
|
+
|
|
194
|
+
return { ok: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (needsDirectFollow(actorUrl)) {
|
|
198
|
+
// tags.pub rejects Fedify's LD Signature context (identity/v1).
|
|
199
|
+
// See: https://github.com/social-web-foundation/tags.pub/issues/10
|
|
200
|
+
const rsaKey = await loadRsaPrivateKey(self);
|
|
201
|
+
if (rsaKey) {
|
|
202
|
+
const result = await sendDirectUnfollow({
|
|
203
|
+
actorUri: ctx.getActorUri(handle).href,
|
|
204
|
+
targetActorUrl: actorUrl,
|
|
205
|
+
inboxUrl: remoteActor.inboxId?.href,
|
|
206
|
+
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
|
207
|
+
privateKey: rsaKey,
|
|
208
|
+
});
|
|
209
|
+
if (!result.ok) {
|
|
210
|
+
console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
const follow = new Follow({
|
|
215
|
+
actor: ctx.getActorUri(handle),
|
|
216
|
+
object: new URL(actorUrl),
|
|
217
|
+
});
|
|
218
|
+
const undo = new Undo({
|
|
219
|
+
actor: ctx.getActorUri(handle),
|
|
220
|
+
object: follow,
|
|
221
|
+
});
|
|
222
|
+
await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
|
|
223
|
+
orderingKey: actorUrl,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
await self._collections.ap_following.deleteOne({ actorUrl });
|
|
227
|
+
|
|
228
|
+
console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
|
|
229
|
+
|
|
230
|
+
await logActivity(self._collections.ap_activities, {
|
|
231
|
+
direction: "outbound",
|
|
232
|
+
type: "Undo(Follow)",
|
|
233
|
+
actorUrl: self._publicationUrl,
|
|
234
|
+
objectUrl: actorUrl,
|
|
235
|
+
summary: `Sent Undo(Follow) to ${actorUrl}`,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return { ok: true };
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message);
|
|
241
|
+
|
|
242
|
+
await logActivity(self._collections.ap_activities, {
|
|
243
|
+
direction: "outbound",
|
|
244
|
+
type: "Undo(Follow)",
|
|
245
|
+
actorUrl: self._publicationUrl,
|
|
246
|
+
objectUrl: actorUrl,
|
|
247
|
+
summary: `Unfollow failed for ${actorUrl}: ${error.message}`,
|
|
248
|
+
}).catch(() => {});
|
|
249
|
+
|
|
250
|
+
// Remove locally even if remote delivery fails
|
|
251
|
+
await self._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});
|
|
252
|
+
return { ok: false, error: error.message };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Send an Update(Person) to all followers so they re-fetch the actor. */
|
|
257
|
+
export async function broadcastActorUpdate(self) {
|
|
258
|
+
if (!self._federation) return;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const { Update } = await import("@fedify/fedify/vocab");
|
|
262
|
+
const handle = self.options.actor.handle;
|
|
263
|
+
const ctx = self._federation.createContext(
|
|
264
|
+
new URL(self._publicationUrl),
|
|
265
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const actor = await buildPersonActor(
|
|
269
|
+
ctx,
|
|
270
|
+
handle,
|
|
271
|
+
self._collections,
|
|
272
|
+
self.options.actorType,
|
|
273
|
+
);
|
|
274
|
+
if (!actor) {
|
|
275
|
+
console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const update = new Update({
|
|
280
|
+
actor: ctx.getActorUri(handle),
|
|
281
|
+
object: actor,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await batchBroadcast({
|
|
285
|
+
federation: self._federation,
|
|
286
|
+
collections: self._collections,
|
|
287
|
+
publicationUrl: self._publicationUrl,
|
|
288
|
+
handle,
|
|
289
|
+
activity: update,
|
|
290
|
+
label: "Update(Person)",
|
|
291
|
+
objectUrl: getActorUrl(self),
|
|
292
|
+
});
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Send a Delete activity to all followers for a removed post. */
|
|
299
|
+
export async function broadcastDelete(self, postUrl) {
|
|
300
|
+
if (!self._federation) return;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const { Delete } = await import("@fedify/fedify/vocab");
|
|
304
|
+
const handle = self.options.actor.handle;
|
|
305
|
+
const ctx = self._federation.createContext(
|
|
306
|
+
new URL(self._publicationUrl),
|
|
307
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const del = new Delete({
|
|
311
|
+
actor: ctx.getActorUri(handle),
|
|
312
|
+
object: new URL(postUrl),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await batchBroadcast({
|
|
316
|
+
federation: self._federation,
|
|
317
|
+
collections: self._collections,
|
|
318
|
+
publicationUrl: self._publicationUrl,
|
|
319
|
+
handle,
|
|
320
|
+
activity: del,
|
|
321
|
+
label: "Delete",
|
|
322
|
+
objectUrl: postUrl,
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
console.warn("[ActivityPub] broadcastDelete failed:", error.message);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Micropub delete hook: record a tombstone (FEP-4f05) + broadcast Delete. */
|
|
330
|
+
export async function deletePost(self, url) {
|
|
331
|
+
// Record tombstone for FEP-4f05
|
|
332
|
+
try {
|
|
333
|
+
const { addTombstone } = await import("./storage/tombstones.js");
|
|
334
|
+
const postsCol = self._collections.posts;
|
|
335
|
+
const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
|
|
336
|
+
await addTombstone(self._collections, {
|
|
337
|
+
url,
|
|
338
|
+
formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
|
|
339
|
+
published: post?.properties?.published || null,
|
|
340
|
+
deleted: new Date().toISOString(),
|
|
341
|
+
});
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await broadcastDelete(self, url).catch((err) =>
|
|
347
|
+
console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Micropub update hook: broadcast an Update for the modified post. */
|
|
352
|
+
export async function updatePost(self, properties) {
|
|
353
|
+
await broadcastPostUpdate(self, properties).catch((err) =>
|
|
354
|
+
console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Send an Update activity to all followers for a modified post. */
|
|
359
|
+
export async function broadcastPostUpdate(self, properties) {
|
|
360
|
+
if (!self._federation) return;
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const { Update } = await import("@fedify/fedify/vocab");
|
|
364
|
+
const actorUrl = getActorUrl(self);
|
|
365
|
+
const handle = self.options.actor.handle;
|
|
366
|
+
const ctx = self._federation.createContext(
|
|
367
|
+
new URL(self._publicationUrl),
|
|
368
|
+
{ handle, publicationUrl: self._publicationUrl },
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const createActivity = jf2ToAS2Activity(
|
|
372
|
+
properties,
|
|
373
|
+
actorUrl,
|
|
374
|
+
self._publicationUrl,
|
|
375
|
+
{ visibility: self.options.defaultVisibility },
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
if (!createActivity) {
|
|
379
|
+
console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const noteObject = await createActivity.getObject();
|
|
384
|
+
const activity = new Update({
|
|
385
|
+
actor: ctx.getActorUri(handle),
|
|
386
|
+
object: noteObject,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await batchBroadcast({
|
|
390
|
+
federation: self._federation,
|
|
391
|
+
collections: self._collections,
|
|
392
|
+
publicationUrl: self._publicationUrl,
|
|
393
|
+
handle,
|
|
394
|
+
activity,
|
|
395
|
+
label: "Update(Note)",
|
|
396
|
+
objectUrl: properties.url,
|
|
397
|
+
});
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Build the full actor URL from config. */
|
|
404
|
+
export function getActorUrl(self) {
|
|
405
|
+
const base = self._publicationUrl.replace(/\/$/, "");
|
|
406
|
+
return `${base}${self.options.mountPath}/users/${self.options.actor.handle}`;
|
|
407
|
+
}
|
|
@@ -126,9 +126,13 @@ export function buildContentNegotiationRoutes(self) {
|
|
|
126
126
|
return next();
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Match regardless of trailing slash: posts are stored without one, but
|
|
130
|
+
// AS2 dereference requests (and nginx AS2 proxy passthrough) can arrive
|
|
131
|
+
// with a trailing slash. Try both so content negotiation stays robust.
|
|
129
132
|
const requestUrl = `${self._publicationUrl}${req.path.slice(1)}`;
|
|
133
|
+
const requestUrlNoSlash = requestUrl.replace(/\/$/, "");
|
|
130
134
|
const post = await postsCollection.findOne({
|
|
131
|
-
"properties.url":
|
|
135
|
+
"properties.url": { $in: [requestUrlNoSlash, `${requestUrlNoSlash}/`] },
|
|
132
136
|
});
|
|
133
137
|
|
|
134
138
|
if (!post || post.properties?.deleted) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.13",
|
|
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",
|