@rmdes/indiekit-endpoint-activitypub 3.13.2 → 3.13.3

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
@@ -1119,6 +1119,7 @@ export default class ActivityPubEndpoint {
1119
1119
  federation: this._federation,
1120
1120
  followActor: (url, info) => pluginRef.followActor(url, info),
1121
1121
  unfollowActor: (url) => pluginRef.unfollowActor(url),
1122
+ broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
1122
1123
  loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
1123
1124
  },
1124
1125
  });
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Deterministic ID mapping for Mastodon Client API.
3
3
  *
4
- * Local accounts use MongoDB _id.toString().
5
- * Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs
6
- * without requiring a dedicated accounts collection.
4
+ * All accounts (local and remote) use sha256(actorUrl).slice(0, 24)
5
+ * for stable, consistent IDs. This ensures verify_credentials and
6
+ * status serialization produce the same ID for the local user,
7
+ * even though the profile doc has _id but timeline author objects don't.
7
8
  */
8
9
  import crypto from "node:crypto";
9
10
 
10
11
  /**
11
- * Generate a deterministic ID for a remote actor URL.
12
- * @param {string} actorUrl - The remote actor's URL
12
+ * Generate a deterministic ID for an actor URL.
13
+ * @param {string} actorUrl - The actor's URL
13
14
  * @returns {string} 24-character hex ID
14
15
  */
15
16
  export function remoteActorId(actorUrl) {
@@ -18,15 +19,13 @@ export function remoteActorId(actorUrl) {
18
19
 
19
20
  /**
20
21
  * Get the Mastodon API ID for an account.
22
+ * Uses URL-based hash for all accounts (local and remote) so the ID
23
+ * is consistent regardless of whether the actor object has a MongoDB _id.
21
24
  * @param {object} actor - Actor object (local profile or remote author)
22
- * @param {boolean} isLocal - Whether this is the local profile
25
+ * @param {boolean} _isLocal - Unused (kept for API compatibility)
23
26
  * @returns {string}
24
27
  */
25
- export function accountId(actor, isLocal = false) {
26
- if (isLocal && actor._id) {
27
- return actor._id.toString();
28
- }
29
- // Remote actors: use URL-based deterministic hash
28
+ export function accountId(actor, _isLocal = false) {
30
29
  const url = actor.url || actor.actorUrl || "";
31
30
  return url ? remoteActorId(url) : "0";
32
31
  }
@@ -306,6 +306,13 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
306
306
 
307
307
  if (Object.keys(update).length > 0 && collections.ap_profile) {
308
308
  await collections.ap_profile.updateOne({}, { $set: update });
309
+
310
+ // Broadcast Update(Person) to followers so profile changes federate
311
+ if (pluginOptions.broadcastActorUpdate) {
312
+ pluginOptions.broadcastActorUpdate().catch((err) =>
313
+ console.warn(`[Mastodon API] broadcastActorUpdate failed: ${err.message}`),
314
+ );
315
+ }
309
316
  }
310
317
 
311
318
  // Return updated credential account
@@ -313,12 +320,23 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired
313
320
  ? await collections.ap_profile.findOne({})
314
321
  : {};
315
322
 
323
+ const handle = pluginOptions.handle || "user";
324
+ let counts = {};
325
+ try {
326
+ const [statuses, followers, following] = await Promise.all([
327
+ collections.ap_timeline.countDocuments({ "author.url": profile.url }),
328
+ collections.ap_followers.countDocuments({}),
329
+ collections.ap_following.countDocuments({}),
330
+ ]);
331
+ counts = { statuses, followers, following };
332
+ } catch {
333
+ counts = { statuses: 0, followers: 0, following: 0 };
334
+ }
335
+
316
336
  const { serializeCredentialAccount } = await import(
317
337
  "../entities/account.js"
318
338
  );
319
- res.json(
320
- await serializeCredentialAccount(profile, { baseUrl, collections }),
321
- );
339
+ res.json(serializeCredentialAccount(profile, { baseUrl, handle, counts }));
322
340
  } catch (error) {
323
341
  next(error);
324
342
  }
@@ -466,6 +466,13 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
466
466
  if (statusText !== undefined) {
467
467
  updatePayload.replace.content = [statusText];
468
468
  }
469
+ if (spoilerText !== undefined) {
470
+ updatePayload.replace["content-warning"] = spoilerText ? [spoilerText] : [];
471
+ updatePayload.replace.sensitive = [spoilerText ? "true" : "false"];
472
+ }
473
+ if (sensitive !== undefined && spoilerText === undefined) {
474
+ updatePayload.replace.sensitive = [sensitive === true || sensitive === "true" ? "true" : "false"];
475
+ }
469
476
 
470
477
  try {
471
478
  await fetch(micropubUrl, {
@@ -604,23 +611,25 @@ router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("r
604
611
  );
605
612
  if (!item) return res.status(404).json({ error: "Record not found" });
606
613
 
607
- const uid = item.uid || item.url;
608
- if (!uid || !collections.ap_interactions) return res.json([]);
614
+ const targetUrl = item.uid || item.url;
615
+ if (!targetUrl || !collections.ap_notifications) return res.json([]);
609
616
 
610
- const interactions = await collections.ap_interactions
611
- .find({ objectUrl: uid, type: "like" })
617
+ // Incoming likes are stored as notifications by the inbox handler
618
+ const notifications = await collections.ap_notifications
619
+ .find({ targetUrl, type: "like" })
612
620
  .limit(40)
613
621
  .toArray();
614
622
 
615
623
  const { serializeAccount } = await import("../entities/account.js");
616
- const accounts = interactions
617
- .filter((i) => i.actorUrl || i.actorName)
618
- .map((i) =>
624
+ const accounts = notifications
625
+ .filter((n) => n.actorUrl)
626
+ .map((n) =>
619
627
  serializeAccount(
620
628
  {
621
- url: i.actorUrl,
622
- name: i.actorName || "",
623
- handle: i.actorHandle || "",
629
+ url: n.actorUrl,
630
+ name: n.actorName || "",
631
+ handle: n.actorHandle || "",
632
+ photo: n.actorPhoto || "",
624
633
  },
625
634
  { baseUrl, isLocal: false },
626
635
  ),
@@ -645,23 +654,25 @@ router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("re
645
654
  );
646
655
  if (!item) return res.status(404).json({ error: "Record not found" });
647
656
 
648
- const uid = item.uid || item.url;
649
- if (!uid || !collections.ap_interactions) return res.json([]);
657
+ const targetUrl = item.uid || item.url;
658
+ if (!targetUrl || !collections.ap_notifications) return res.json([]);
650
659
 
651
- const interactions = await collections.ap_interactions
652
- .find({ objectUrl: uid, type: "boost" })
660
+ // Incoming boosts are stored as notifications by the inbox handler
661
+ const notifications = await collections.ap_notifications
662
+ .find({ targetUrl, type: "boost" })
653
663
  .limit(40)
654
664
  .toArray();
655
665
 
656
666
  const { serializeAccount } = await import("../entities/account.js");
657
- const accounts = interactions
658
- .filter((i) => i.actorUrl || i.actorName)
659
- .map((i) =>
667
+ const accounts = notifications
668
+ .filter((n) => n.actorUrl)
669
+ .map((n) =>
660
670
  serializeAccount(
661
671
  {
662
- url: i.actorUrl,
663
- name: i.actorName || "",
664
- handle: i.actorHandle || "",
672
+ url: n.actorUrl,
673
+ name: n.actorName || "",
674
+ handle: n.actorHandle || "",
675
+ photo: n.actorPhoto || "",
665
676
  },
666
677
  { baseUrl, isLocal: false },
667
678
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.13.2",
3
+ "version": "3.13.3",
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",