@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
|
-
|
|
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.
|
|
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",
|