@rmdes/indiekit-endpoint-activitypub 1.0.5 → 1.0.7

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 CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  profileGetController,
22
22
  profilePostController,
23
23
  } from "./lib/controllers/profile.js";
24
+ import { logActivity } from "./lib/activity-log.js";
24
25
 
25
26
  const defaults = {
26
27
  mountPath: "/activitypub",
@@ -256,6 +257,13 @@ export default class ActivityPubEndpoint {
256
257
  );
257
258
 
258
259
  if (!activity) {
260
+ await logActivity(self._collections.ap_activities, {
261
+ direction: "outbound",
262
+ type: "Syndicate",
263
+ actorUrl: self._publicationUrl,
264
+ objectUrl: properties.url,
265
+ summary: `Syndication skipped: could not convert post to AS2`,
266
+ });
259
267
  return undefined;
260
268
  }
261
269
 
@@ -264,21 +272,225 @@ export default class ActivityPubEndpoint {
264
272
  {},
265
273
  );
266
274
 
275
+ // Count followers for logging
276
+ const followerCount =
277
+ await self._collections.ap_followers.countDocuments();
278
+
279
+ console.info(
280
+ `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
281
+ );
282
+
267
283
  await ctx.sendActivity(
268
284
  { identifier: self.options.actor.handle },
269
285
  "followers",
270
286
  activity,
271
287
  );
272
288
 
289
+ // Determine activity type name
290
+ const typeName =
291
+ activity.constructor?.name || "Create";
292
+
293
+ await logActivity(self._collections.ap_activities, {
294
+ direction: "outbound",
295
+ type: typeName,
296
+ actorUrl: self._publicationUrl,
297
+ objectUrl: properties.url,
298
+ summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers`,
299
+ });
300
+
301
+ console.info(
302
+ `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}`,
303
+ );
304
+
273
305
  return properties.url || undefined;
274
306
  } catch (error) {
275
307
  console.error("[ActivityPub] Syndication failed:", error.message);
308
+ await logActivity(self._collections.ap_activities, {
309
+ direction: "outbound",
310
+ type: "Syndicate",
311
+ actorUrl: self._publicationUrl,
312
+ objectUrl: properties.url,
313
+ summary: `Syndication failed: ${error.message}`,
314
+ }).catch(() => {});
276
315
  return undefined;
277
316
  }
278
317
  },
279
318
  };
280
319
  }
281
320
 
321
+ /**
322
+ * Send a Follow activity to a remote actor and store in ap_following.
323
+ * @param {string} actorUrl - The remote actor's URL
324
+ * @param {object} [actorInfo] - Optional pre-fetched actor info
325
+ * @param {string} [actorInfo.name] - Actor display name
326
+ * @param {string} [actorInfo.handle] - Actor handle
327
+ * @param {string} [actorInfo.photo] - Actor avatar URL
328
+ * @returns {Promise<{ok: boolean, error?: string}>}
329
+ */
330
+ async followActor(actorUrl, actorInfo = {}) {
331
+ if (!this._federation) {
332
+ return { ok: false, error: "Federation not initialized" };
333
+ }
334
+
335
+ try {
336
+ const { Follow } = await import("@fedify/fedify");
337
+ const handle = this.options.actor.handle;
338
+ const ctx = this._federation.createContext(
339
+ new URL(this._publicationUrl),
340
+ {},
341
+ );
342
+
343
+ // Resolve the remote actor to get their inbox
344
+ const remoteActor = await ctx.lookupObject(actorUrl);
345
+ if (!remoteActor) {
346
+ return { ok: false, error: "Could not resolve remote actor" };
347
+ }
348
+
349
+ // Send Follow activity
350
+ const follow = new Follow({
351
+ actor: ctx.getActorUri(handle),
352
+ object: new URL(actorUrl),
353
+ });
354
+
355
+ await ctx.sendActivity({ identifier: handle }, remoteActor, follow);
356
+
357
+ // Store in ap_following
358
+ const name =
359
+ actorInfo.name ||
360
+ remoteActor.name?.toString() ||
361
+ remoteActor.preferredUsername?.toString() ||
362
+ actorUrl;
363
+ const actorHandle =
364
+ actorInfo.handle ||
365
+ remoteActor.preferredUsername?.toString() ||
366
+ "";
367
+ const avatar =
368
+ actorInfo.photo ||
369
+ (remoteActor.icon
370
+ ? (await remoteActor.icon)?.url?.href || ""
371
+ : "");
372
+ const inbox = remoteActor.inbox?.id?.href || "";
373
+ const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
374
+
375
+ await this._collections.ap_following.updateOne(
376
+ { actorUrl },
377
+ {
378
+ $set: {
379
+ actorUrl,
380
+ handle: actorHandle,
381
+ name,
382
+ avatar,
383
+ inbox,
384
+ sharedInbox,
385
+ followedAt: new Date().toISOString(),
386
+ source: "microsub-reader",
387
+ },
388
+ },
389
+ { upsert: true },
390
+ );
391
+
392
+ console.info(`[ActivityPub] Sent Follow to ${actorUrl}`);
393
+
394
+ await logActivity(this._collections.ap_activities, {
395
+ direction: "outbound",
396
+ type: "Follow",
397
+ actorUrl: this._publicationUrl,
398
+ objectUrl: actorUrl,
399
+ actorName: name,
400
+ summary: `Sent Follow to ${name} (${actorUrl})`,
401
+ });
402
+
403
+ return { ok: true };
404
+ } catch (error) {
405
+ console.error(`[ActivityPub] Follow failed for ${actorUrl}:`, error.message);
406
+
407
+ await logActivity(this._collections.ap_activities, {
408
+ direction: "outbound",
409
+ type: "Follow",
410
+ actorUrl: this._publicationUrl,
411
+ objectUrl: actorUrl,
412
+ summary: `Follow failed for ${actorUrl}: ${error.message}`,
413
+ }).catch(() => {});
414
+
415
+ return { ok: false, error: error.message };
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Send an Undo(Follow) activity and remove from ap_following.
421
+ * @param {string} actorUrl - The remote actor's URL
422
+ * @returns {Promise<{ok: boolean, error?: string}>}
423
+ */
424
+ async unfollowActor(actorUrl) {
425
+ if (!this._federation) {
426
+ return { ok: false, error: "Federation not initialized" };
427
+ }
428
+
429
+ try {
430
+ const { Follow, Undo } = await import("@fedify/fedify");
431
+ const handle = this.options.actor.handle;
432
+ const ctx = this._federation.createContext(
433
+ new URL(this._publicationUrl),
434
+ {},
435
+ );
436
+
437
+ const remoteActor = await ctx.lookupObject(actorUrl);
438
+ if (!remoteActor) {
439
+ // Even if we can't resolve, remove locally
440
+ await this._collections.ap_following.deleteOne({ actorUrl });
441
+
442
+ await logActivity(this._collections.ap_activities, {
443
+ direction: "outbound",
444
+ type: "Undo(Follow)",
445
+ actorUrl: this._publicationUrl,
446
+ objectUrl: actorUrl,
447
+ summary: `Removed ${actorUrl} locally (could not resolve remote actor)`,
448
+ }).catch(() => {});
449
+
450
+ return { ok: true };
451
+ }
452
+
453
+ const follow = new Follow({
454
+ actor: ctx.getActorUri(handle),
455
+ object: new URL(actorUrl),
456
+ });
457
+
458
+ const undo = new Undo({
459
+ actor: ctx.getActorUri(handle),
460
+ object: follow,
461
+ });
462
+
463
+ await ctx.sendActivity({ identifier: handle }, remoteActor, undo);
464
+ await this._collections.ap_following.deleteOne({ actorUrl });
465
+
466
+ console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
467
+
468
+ await logActivity(this._collections.ap_activities, {
469
+ direction: "outbound",
470
+ type: "Undo(Follow)",
471
+ actorUrl: this._publicationUrl,
472
+ objectUrl: actorUrl,
473
+ summary: `Sent Undo(Follow) to ${actorUrl}`,
474
+ });
475
+
476
+ return { ok: true };
477
+ } catch (error) {
478
+ console.error(`[ActivityPub] Unfollow failed for ${actorUrl}:`, error.message);
479
+
480
+ await logActivity(this._collections.ap_activities, {
481
+ direction: "outbound",
482
+ type: "Undo(Follow)",
483
+ actorUrl: this._publicationUrl,
484
+ objectUrl: actorUrl,
485
+ summary: `Unfollow failed for ${actorUrl}: ${error.message}`,
486
+ }).catch(() => {});
487
+
488
+ // Remove locally even if remote delivery fails
489
+ await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});
490
+ return { ok: false, error: error.message };
491
+ }
492
+ }
493
+
282
494
  /**
283
495
  * Build the full actor URL from config.
284
496
  * @returns {string}
@@ -316,6 +528,13 @@ export default class ActivityPubEndpoint {
316
528
  get posts() {
317
529
  return indiekitCollections.get("posts");
318
530
  },
531
+ // Lazy access to Microsub collections (may not exist if plugin not loaded)
532
+ get microsub_items() {
533
+ return indiekitCollections.get("microsub_items");
534
+ },
535
+ get microsub_channels() {
536
+ return indiekitCollections.get("microsub_channels");
537
+ },
319
538
  _publicationUrl: this._publicationUrl,
320
539
  };
321
540
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared activity logging utility.
3
+ *
4
+ * Logs inbound and outbound ActivityPub activities to the ap_activities
5
+ * collection so they appear in the Activity Log admin UI.
6
+ */
7
+
8
+ /**
9
+ * Log an activity to the ap_activities collection.
10
+ *
11
+ * @param {object} collection - The ap_activities MongoDB collection
12
+ * @param {object} record - Activity record
13
+ * @param {string} record.direction - "inbound" or "outbound"
14
+ * @param {string} record.type - Activity type (e.g. "Create", "Follow", "Undo(Follow)")
15
+ * @param {string} [record.actorUrl] - Actor URL
16
+ * @param {string} [record.actorName] - Actor display name
17
+ * @param {string} [record.objectUrl] - Object URL
18
+ * @param {string} [record.targetUrl] - Target URL (e.g. reply target)
19
+ * @param {string} [record.content] - Content excerpt
20
+ * @param {string} record.summary - Human-readable summary
21
+ */
22
+ export async function logActivity(collection, record) {
23
+ try {
24
+ await collection.insertOne({
25
+ ...record,
26
+ receivedAt: new Date().toISOString(),
27
+ });
28
+ } catch (error) {
29
+ console.warn("[ActivityPub] Failed to log activity:", error.message);
30
+ }
31
+ }
@@ -16,6 +16,7 @@ import {
16
16
  createFederation,
17
17
  importSpki,
18
18
  } from "@fedify/fedify";
19
+ import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
19
20
  import { MongoKvStore } from "./kv-store.js";
20
21
  import { registerInboxListeners } from "./inbox-listeners.js";
21
22
 
@@ -29,6 +30,9 @@ import { registerInboxListeners } from "./inbox-listeners.js";
29
30
  * @param {boolean} options.storeRawActivities - Whether to store full raw JSON
30
31
  * @returns {{ federation: import("@fedify/fedify").Federation }}
31
32
  */
33
+ // Track whether LogTape has been configured (can only call configure() once)
34
+ let _logtapeConfigured = false;
35
+
32
36
  export function setupFederation(options) {
33
37
  const {
34
38
  collections,
@@ -37,6 +41,26 @@ export function setupFederation(options) {
37
41
  storeRawActivities = false,
38
42
  } = options;
39
43
 
44
+ // Configure LogTape for Fedify delivery logging (once per process)
45
+ if (!_logtapeConfigured) {
46
+ _logtapeConfigured = true;
47
+ configure({
48
+ sinks: {
49
+ console: getConsoleSink(),
50
+ },
51
+ loggers: [
52
+ {
53
+ // All Fedify logs — federation, vocab, delivery, HTTP signatures
54
+ category: ["fedify"],
55
+ sinks: ["console"],
56
+ lowestLevel: "info",
57
+ },
58
+ ],
59
+ }).catch((error) => {
60
+ console.warn("[ActivityPub] LogTape configure failed:", error.message);
61
+ });
62
+ }
63
+
40
64
  const federation = createFederation({
41
65
  kv: new MongoKvStore(collections.ap_kv),
42
66
  queue: new InProcessMessageQueue(),
@@ -21,6 +21,8 @@ import {
21
21
  Update,
22
22
  } from "@fedify/fedify";
23
23
 
24
+ import { logActivity as logActivityShared } from "./activity-log.js";
25
+
24
26
  /**
25
27
  * Register all inbox listeners on a federation's inbox chain.
26
28
  *
@@ -157,12 +159,6 @@ export function registerInboxListeners(inboxChain, options) {
157
159
  const object = await create.getObject();
158
160
  if (!object) return;
159
161
 
160
- const inReplyTo =
161
- object instanceof Note
162
- ? (await object.getInReplyTo())?.id?.href
163
- : null;
164
- if (!inReplyTo) return;
165
-
166
162
  const actorObj = await create.getActor();
167
163
  const actorUrl = actorObj?.id?.href || "";
168
164
  const actorName =
@@ -170,18 +166,33 @@ export function registerInboxListeners(inboxChain, options) {
170
166
  actorObj?.preferredUsername?.toString() ||
171
167
  actorUrl;
172
168
 
173
- // Extract reply content (HTML)
174
- const content = object.content?.toString() || "";
169
+ const inReplyTo =
170
+ object instanceof Note
171
+ ? (await object.getInReplyTo())?.id?.href
172
+ : null;
173
+
174
+ // Log replies to our posts (existing behavior for conversations)
175
+ if (inReplyTo) {
176
+ const content = object.content?.toString() || "";
177
+ await logActivity(collections, storeRawActivities, {
178
+ direction: "inbound",
179
+ type: "Reply",
180
+ actorUrl,
181
+ actorName,
182
+ objectUrl: object.id?.href || "",
183
+ targetUrl: inReplyTo,
184
+ content,
185
+ summary: `${actorName} replied to ${inReplyTo}`,
186
+ });
187
+ }
175
188
 
176
- await logActivity(collections, storeRawActivities, {
177
- direction: "inbound",
178
- type: "Reply",
189
+ // Store timeline items from accounts we follow
190
+ await storeTimelineItem(collections, {
179
191
  actorUrl,
180
192
  actorName,
181
- objectUrl: object.id?.href || "",
182
- targetUrl: inReplyTo,
183
- content,
184
- summary: `${actorName} replied to ${inReplyTo}`,
193
+ actorObj,
194
+ object,
195
+ inReplyTo,
185
196
  });
186
197
  })
187
198
  .on(Delete, async (ctx, del) => {
@@ -255,10 +266,149 @@ export function registerInboxListeners(inboxChain, options) {
255
266
 
256
267
  /**
257
268
  * Log an activity to the ap_activities collection.
269
+ * Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
270
+ * used throughout this file.
258
271
  */
259
272
  async function logActivity(collections, storeRaw, record) {
260
- await collections.ap_activities.insertOne({
261
- ...record,
262
- receivedAt: new Date().toISOString(),
273
+ await logActivityShared(collections.ap_activities, record);
274
+ }
275
+
276
+ // Cached ActivityPub channel ObjectId
277
+ let _apChannelId = null;
278
+
279
+ /**
280
+ * Look up the ActivityPub channel's ObjectId (cached after first call).
281
+ * @param {object} collections - MongoDB collections
282
+ * @returns {Promise<import("mongodb").ObjectId|null>}
283
+ */
284
+ async function getApChannelId(collections) {
285
+ if (_apChannelId) return _apChannelId;
286
+ const channel = await collections.microsub_channels?.findOne({
287
+ uid: "activitypub",
263
288
  });
289
+ _apChannelId = channel?._id || null;
290
+ return _apChannelId;
291
+ }
292
+
293
+ /**
294
+ * Store a Create activity as a Microsub timeline item if the actor
295
+ * is someone we follow. Skips gracefully if the Microsub plugin
296
+ * isn't loaded or the AP channel doesn't exist yet.
297
+ *
298
+ * @param {object} collections - MongoDB collections
299
+ * @param {object} params
300
+ * @param {string} params.actorUrl - Actor URL
301
+ * @param {string} params.actorName - Actor display name
302
+ * @param {object} params.actorObj - Fedify actor object
303
+ * @param {object} params.object - Fedify Note/Article object
304
+ * @param {string|null} params.inReplyTo - URL this is a reply to (if any)
305
+ */
306
+ async function storeTimelineItem(
307
+ collections,
308
+ { actorUrl, actorName, actorObj, object, inReplyTo },
309
+ ) {
310
+ // Skip if Microsub plugin not loaded
311
+ if (!collections.microsub_items || !collections.microsub_channels) return;
312
+
313
+ // Only store posts from accounts we follow
314
+ const following = await collections.ap_following.findOne({ actorUrl });
315
+ if (!following) return;
316
+
317
+ const channelId = await getApChannelId(collections);
318
+ if (!channelId) return;
319
+
320
+ const objectUrl = object.id?.href || "";
321
+ if (!objectUrl) return;
322
+
323
+ // Extract content
324
+ const contentHtml = object.content?.toString() || "";
325
+ const contentText = contentHtml.replace(/<[^>]*>/g, "").trim();
326
+
327
+ // Name (usually only on Article, not Note)
328
+ const name = object.name?.toString() || undefined;
329
+ const summary = object.summary?.toString() || undefined;
330
+
331
+ // Published date — Fedify returns Temporal.Instant
332
+ let published;
333
+ if (object.published) {
334
+ try {
335
+ published = new Date(Number(object.published.epochMilliseconds));
336
+ } catch {
337
+ published = new Date();
338
+ }
339
+ }
340
+
341
+ // Author avatar
342
+ let authorPhoto = "";
343
+ try {
344
+ if (actorObj.icon) {
345
+ const iconObj = await actorObj.icon;
346
+ authorPhoto = iconObj?.url?.href || "";
347
+ }
348
+ } catch {
349
+ /* remote fetch may fail */
350
+ }
351
+
352
+ // Tags / categories
353
+ const category = [];
354
+ try {
355
+ for await (const tag of object.getTags()) {
356
+ const tagName = tag.name?.toString();
357
+ if (tagName) category.push(tagName.replace(/^#/, ""));
358
+ }
359
+ } catch {
360
+ /* ignore */
361
+ }
362
+
363
+ // Attachments (photos, videos, audio)
364
+ const photo = [];
365
+ const video = [];
366
+ const audio = [];
367
+ try {
368
+ for await (const att of object.getAttachments()) {
369
+ const mediaType = att.mediaType?.toString() || "";
370
+ const url = att.url?.href || att.id?.href || "";
371
+ if (!url) continue;
372
+ if (mediaType.startsWith("image/")) photo.push(url);
373
+ else if (mediaType.startsWith("video/")) video.push(url);
374
+ else if (mediaType.startsWith("audio/")) audio.push(url);
375
+ }
376
+ } catch {
377
+ /* ignore */
378
+ }
379
+
380
+ const item = {
381
+ channelId,
382
+ feedId: null,
383
+ uid: objectUrl,
384
+ type: "entry",
385
+ url: objectUrl,
386
+ name,
387
+ content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
388
+ summary,
389
+ published: published || new Date(),
390
+ author: {
391
+ name: actorName,
392
+ url: actorUrl,
393
+ photo: authorPhoto,
394
+ },
395
+ category,
396
+ photo,
397
+ video,
398
+ audio,
399
+ inReplyTo: inReplyTo ? [inReplyTo] : [],
400
+ source: {
401
+ type: "activitypub",
402
+ actorUrl,
403
+ },
404
+ readBy: [],
405
+ createdAt: new Date().toISOString(),
406
+ };
407
+
408
+ // Atomic upsert — prevents duplicates without a separate check+insert
409
+ await collections.microsub_items.updateOne(
410
+ { channelId, uid: objectUrl },
411
+ { $setOnInsert: item },
412
+ { upsert: true },
413
+ );
264
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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",