@rmdes/indiekit-endpoint-activitypub 3.6.8 → 3.7.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,97 @@
1
+ /**
2
+ * Enrich embedded account objects in serialized statuses with real
3
+ * follower/following/post counts from remote AP collections.
4
+ *
5
+ * Phanpy (and some other clients) never call /accounts/:id — they
6
+ * trust the account object embedded in each status. Without enrichment,
7
+ * these show 0/0/0 for all remote accounts.
8
+ *
9
+ * Uses the account stats cache to avoid redundant fetches. Only resolves
10
+ * unique authors with 0 counts that aren't already cached.
11
+ */
12
+ import { getCachedAccountStats } from "./account-cache.js";
13
+ import { resolveRemoteAccount } from "./resolve-account.js";
14
+
15
+ /**
16
+ * Enrich account objects in a list of serialized statuses.
17
+ * Resolves unique authors in parallel (max 5 concurrent).
18
+ *
19
+ * @param {Array} statuses - Serialized Mastodon Status objects (mutated in place)
20
+ * @param {object} pluginOptions - Plugin options with federation context
21
+ * @param {string} baseUrl - Server base URL
22
+ */
23
+ export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
24
+ if (!statuses?.length || !pluginOptions?.federation) return;
25
+
26
+ // Collect unique author URLs that need enrichment
27
+ const accountsToEnrich = new Map(); // url -> [account references]
28
+ for (const status of statuses) {
29
+ collectAccount(status.account, accountsToEnrich);
30
+ if (status.reblog?.account) {
31
+ collectAccount(status.reblog.account, accountsToEnrich);
32
+ }
33
+ }
34
+
35
+ if (accountsToEnrich.size === 0) return;
36
+
37
+ // Resolve in parallel with concurrency limit
38
+ const entries = [...accountsToEnrich.entries()];
39
+ const CONCURRENCY = 5;
40
+ for (let i = 0; i < entries.length; i += CONCURRENCY) {
41
+ const batch = entries.slice(i, i + CONCURRENCY);
42
+ await Promise.all(
43
+ batch.map(async ([url, accounts]) => {
44
+ try {
45
+ const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl);
46
+ if (resolved) {
47
+ for (const account of accounts) {
48
+ account.followers_count = resolved.followers_count;
49
+ account.following_count = resolved.following_count;
50
+ account.statuses_count = resolved.statuses_count;
51
+ if (resolved.created_at && account.created_at) {
52
+ account.created_at = resolved.created_at;
53
+ }
54
+ if (resolved.note) account.note = resolved.note;
55
+ if (resolved.fields?.length) account.fields = resolved.fields;
56
+ if (resolved.avatar && resolved.avatar !== account.avatar) {
57
+ account.avatar = resolved.avatar;
58
+ account.avatar_static = resolved.avatar;
59
+ }
60
+ if (resolved.header) {
61
+ account.header = resolved.header;
62
+ account.header_static = resolved.header;
63
+ }
64
+ }
65
+ }
66
+ } catch {
67
+ // Silently skip failed resolutions
68
+ }
69
+ }),
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Collect an account reference for enrichment if it has 0 counts
76
+ * and isn't already cached.
77
+ */
78
+ function collectAccount(account, map) {
79
+ if (!account?.url) return;
80
+ if (account.followers_count > 0 || account.statuses_count > 0) return;
81
+
82
+ // Check cache first — if cached, apply immediately
83
+ const cached = getCachedAccountStats(account.url);
84
+ if (cached) {
85
+ account.followers_count = cached.followersCount || 0;
86
+ account.following_count = cached.followingCount || 0;
87
+ account.statuses_count = cached.statusesCount || 0;
88
+ if (cached.createdAt) account.created_at = cached.createdAt;
89
+ return;
90
+ }
91
+
92
+ // Queue for remote resolution
93
+ if (!map.has(account.url)) {
94
+ map.set(account.url, []);
95
+ }
96
+ map.get(account.url).push(account);
97
+ }
@@ -567,8 +567,17 @@ async function findTimelineItemById(collection, id) {
567
567
  // Try cursor-based lookup first (published date from ms-since-epoch)
568
568
  const publishedDate = decodeCursor(id);
569
569
  if (publishedDate) {
570
- const item = await collection.findOne({ published: publishedDate });
570
+ // Try exact match first (with .000Z suffix from toISOString)
571
+ let item = await collection.findOne({ published: publishedDate });
571
572
  if (item) return item;
573
+
574
+ // Try without milliseconds — stored dates often lack .000Z
575
+ // e.g., "2026-03-21T15:33:50Z" vs "2026-03-21T15:33:50.000Z"
576
+ const withoutMs = publishedDate.replace(/\.000Z$/, "Z");
577
+ if (withoutMs !== publishedDate) {
578
+ item = await collection.findOne({ published: withoutMs });
579
+ if (item) return item;
580
+ }
572
581
  }
573
582
 
574
583
  // Fall back to ObjectId lookup (legacy IDs)
@@ -9,6 +9,7 @@ import express from "express";
9
9
  import { serializeStatus } from "../entities/status.js";
10
10
  import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
11
11
  import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
12
+ import { enrichAccountStats } from "../helpers/enrich-accounts.js";
12
13
 
13
14
  const router = express.Router(); // eslint-disable-line new-cap
14
15
 
@@ -76,6 +77,11 @@ router.get("/api/v1/timelines/home", async (req, res, next) => {
76
77
  }),
77
78
  );
78
79
 
80
+ // Enrich embedded account objects with real follower/following/post counts.
81
+ // Phanpy never calls /accounts/:id — it trusts embedded account data.
82
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
83
+ await enrichAccountStats(statuses, pluginOptions, baseUrl);
84
+
79
85
  // Set pagination Link headers
80
86
  setPaginationHeaders(res, req, items, limit);
81
87
 
@@ -170,6 +176,9 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
170
176
  }),
171
177
  );
172
178
 
179
+ const pluginOpts = req.app.locals.mastodonPluginOptions || {};
180
+ await enrichAccountStats(statuses, pluginOpts, baseUrl);
181
+
173
182
  setPaginationHeaders(res, req, items, limit);
174
183
  res.json(statuses);
175
184
  } catch (error) {
@@ -230,6 +239,9 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
230
239
  }),
231
240
  );
232
241
 
242
+ const pluginOpts = req.app.locals.mastodonPluginOptions || {};
243
+ await enrichAccountStats(statuses, pluginOpts, baseUrl);
244
+
233
245
  setPaginationHeaders(res, req, items, limit);
234
246
  res.json(statuses);
235
247
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.6.8",
3
+ "version": "3.7.0",
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",