fix: route ordering + remote resolution for account profiles

Two bugs causing profile counts to show 0 in Phanpy:

1. Route ordering: /accounts/relationships and /accounts/familiar_followers
   were defined AFTER /accounts/:id. Express matched "relationships" as
   the :id parameter, returning 404. Moved them before the :id catch-all.

2. /accounts/:id only used local data (followers/following/timeline) which
   has no follower counts. Now tries remote actor resolution via Fedify
   to get real counts from AP collection totalItems.
This commit is contained in:
Ricardo
2026-03-21 12:18:38 +01:00
parent bc72bf1e02
commit f9b8baec42
2 changed files with 71 additions and 62 deletions

View File

@@ -155,6 +155,65 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
}
});
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
// MUST be before /accounts/:id to prevent Express matching "relationships" as :id
router.get("/api/v1/accounts/relationships", async (req, res, next) => {
try {
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
if (ids.length === 0) {
return res.json([]);
}
const collections = req.app.locals.mastodonCollections;
const [followers, following, blocked, muted] = await Promise.all([
collections.ap_followers.find({}).toArray(),
collections.ap_following.find({}).toArray(),
collections.ap_blocked.find({}).toArray(),
collections.ap_muted.find({}).toArray(),
]);
const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl)));
const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
const relationships = ids.map((id) => ({
id,
following: followingIds.has(id),
showing_reblogs: followingIds.has(id),
notifying: false,
languages: [],
followed_by: followerIds.has(id),
blocking: blockedIds.has(id),
blocked_by: false,
muting: mutedIds.has(id),
muting_notifications: mutedIds.has(id),
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
}));
res.json(relationships);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
// MUST be before /accounts/:id
router.get("/api/v1/accounts/familiar_followers", (req, res) => {
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
res.json(ids.map((id) => ({ id, accounts: [] })));
});
// ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
router.get("/api/v1/accounts/:id", async (req, res, next) => {
@@ -183,8 +242,18 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
// Resolve remote actor from followers, following, or timeline
const { actor, actorUrl } = await resolveActorData(id, collections);
if (actor) {
// Try remote resolution to get real counts (followers, following, statuses)
const remoteAccount = await resolveRemoteAccount(
actorUrl,
pluginOptions,
baseUrl,
);
if (remoteAccount) {
return res.json(remoteAccount);
}
// Fallback to local data
const account = serializeAccount(actor, { baseUrl });
// Count this actor's posts in our timeline
account.statuses_count = await collections.ap_timeline.countDocuments({
"author.url": actorUrl,
});
@@ -354,66 +423,6 @@ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
}
});
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
router.get("/api/v1/accounts/relationships", async (req, res, next) => {
try {
// id[] can come as single value or array
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
if (ids.length === 0) {
return res.json([]);
}
const collections = req.app.locals.mastodonCollections;
// Load all followers/following for efficient lookup
const [followers, following, blocked, muted] = await Promise.all([
collections.ap_followers.find({}).toArray(),
collections.ap_following.find({}).toArray(),
collections.ap_blocked.find({}).toArray(),
collections.ap_muted.find({}).toArray(),
]);
const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl)));
const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
const relationships = ids.map((id) => ({
id,
following: followingIds.has(id),
showing_reblogs: followingIds.has(id),
notifying: false,
languages: [],
followed_by: followerIds.has(id),
blocking: blockedIds.has(id),
blocked_by: false,
muting: mutedIds.has(id),
muting_notifications: mutedIds.has(id),
requested: false,
requested_by: false,
domain_blocking: false,
endorsed: false,
note: "",
}));
res.json(relationships);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
router.get("/api/v1/accounts/familiar_followers", (req, res) => {
// Stub — returns empty for each requested ID
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
res.json(ids.map((id) => ({ id, accounts: [] })));
});
// ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.6.5",
"version": "3.6.6",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",