feat: resolve remote profiles via WebFinger in Mastodon API

Account lookup (/api/v1/accounts/lookup) and search (/api/v2/search)
now resolve remote actors via Fedify's ctx.lookupObject() when not
found locally. Previously only checked ap_followers — missed accounts
we follow, timeline authors, and any remote actor.

Lookup chain: local profile → followers → following → timeline authors
→ remote WebFinger+actor fetch (Fedify)

Search uses remote resolution when resolve=true and query contains @.
This commit is contained in:
Ricardo
2026-03-21 11:49:12 +01:00
parent 01edd6e92e
commit 9f1287073b
4 changed files with 374 additions and 34 deletions
+79
View File
@@ -0,0 +1,79 @@
/**
* Resolve a remote account via WebFinger + ActivityPub actor fetch.
* Uses the Fedify federation instance to perform discovery.
*
* Shared by accounts.js (lookup) and search.js (resolve=true).
*/
import { serializeAccount } from "../entities/account.js";
/**
* @param {string} acct - Account identifier (user@domain or URL)
* @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl
* @param {string} baseUrl - Server base URL
* @returns {Promise<object|null>} Serialized Mastodon Account or null
*/
export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
const { federation, handle, publicationUrl } = pluginOptions;
if (!federation) return null;
try {
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
// Determine lookup URI
let actorUri;
if (acct.includes("@")) {
const parts = acct.replace(/^@/, "").split("@");
const username = parts[0];
const domain = parts[1];
if (!username || !domain) return null;
actorUri = `acct:${username}@${domain}`;
} else if (acct.startsWith("http")) {
actorUri = acct;
} else {
return null;
}
const actor = await ctx.lookupObject(actorUri);
if (!actor) return null;
// Extract data from the Fedify actor object
const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
const actorUrl = actor.id?.href || "";
const username = actor.preferredUsername?.toString() || "";
const domain = actorUrl ? new URL(actorUrl).hostname : "";
const summary = actor.summary?.toString() || "";
// Get avatar
let avatarUrl = "";
try {
const icon = await actor.getIcon();
avatarUrl = icon?.url?.href || "";
} catch { /* ignore */ }
// Get header image
let headerUrl = "";
try {
const image = await actor.getImage();
headerUrl = image?.url?.href || "";
} catch { /* ignore */ }
return serializeAccount(
{
name,
url: actorUrl,
photo: avatarUrl,
handle: `@${username}@${domain}`,
summary,
image: headerUrl,
bot: actor.constructor?.name === "Service" || actor.constructor?.name === "Application",
},
{ baseUrl },
);
} catch (error) {
console.warn(`[Mastodon API] Remote account resolution failed for ${acct}:`, error.message);
return null;
}
}
+274 -25
View File
@@ -6,7 +6,10 @@
*/ */
import express from "express"; import express from "express";
import { serializeCredentialAccount, serializeAccount } from "../entities/account.js"; import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
import { serializeStatus } from "../entities/status.js";
import { accountId, remoteActorId } from "../helpers/id-mapping.js"; import { accountId, remoteActorId } from "../helpers/id-mapping.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
@@ -98,7 +101,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
} }
} }
// Check followers/following for known remote actors // Check followers for known remote actors
const follower = await collections.ap_followers.findOne({ const follower = await collections.ap_followers.findOne({
$or: [ $or: [
{ handle: `@${bareAcct}` }, { handle: `@${bareAcct}` },
@@ -108,12 +111,44 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
if (follower) { if (follower) {
return res.json( return res.json(
serializeAccount( serializeAccount(
{ name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle }, { name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle, bannerUrl: follower.banner || "" },
{ baseUrl }, { baseUrl },
), ),
); );
} }
// Check following
const following = await collections.ap_following.findOne({
$or: [
{ handle: `@${bareAcct}` },
{ handle: bareAcct },
],
});
if (following) {
return res.json(
serializeAccount(
{ name: following.name, url: following.actorUrl, photo: following.avatar, handle: following.handle },
{ baseUrl },
),
);
}
// Check timeline authors (people whose posts are in our timeline)
const timelineAuthor = await collections.ap_timeline.findOne({
"author.handle": { $in: [`@${bareAcct}`, bareAcct] },
});
if (timelineAuthor?.author) {
return res.json(
serializeAccount(timelineAuthor.author, { baseUrl }),
);
}
// Resolve remotely via federation (WebFinger + actor fetch)
const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl);
if (resolved) {
return res.json(resolved);
}
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -133,47 +168,187 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
// Check if it's the local profile // Check if it's the local profile
const profile = await collections.ap_profile.findOne({}); const profile = await collections.ap_profile.findOne({});
if (profile && profile._id.toString() === id) { if (profile && profile._id.toString() === id) {
return res.json( const [statuses, followers, following] = await Promise.all([
serializeAccount(profile, { baseUrl, isLocal: true, handle }), collections.ap_timeline.countDocuments({ "author.url": profile.url }),
); collections.ap_followers.countDocuments({}),
collections.ap_following.countDocuments({}),
]);
const account = serializeAccount(profile, { baseUrl, isLocal: true, handle });
account.statuses_count = statuses;
account.followers_count = followers;
account.following_count = following;
return res.json(account);
} }
// Search known actors (followers, following, timeline authors) // Resolve remote actor from followers, following, or timeline
// by checking if the deterministic hash matches const { actor, actorUrl } = await resolveActorData(id, collections);
const follower = await collections.ap_followers if (actor) {
.find({}) const account = serializeAccount(actor, { baseUrl });
// Count this actor's posts in our timeline
account.statuses_count = await collections.ap_timeline.countDocuments({
"author.url": actorUrl,
});
return res.json(account);
}
return res.status(404).json({ error: "Record not found" });
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
// Resolve account ID to an author URL
const actorUrl = await resolveActorUrl(id, collections);
if (!actorUrl) {
return res.status(404).json({ error: "Record not found" });
}
// Build filter for this author's posts
const baseFilter = {
"author.url": actorUrl,
isContext: { $ne: true },
};
// Mastodon filters
if (req.query.only_media === "true") {
baseFilter.$or = [
{ "photo.0": { $exists: true } },
{ "video.0": { $exists: true } },
{ "audio.0": { $exists: true } },
];
}
if (req.query.exclude_replies === "true") {
baseFilter.inReplyTo = { $exists: false };
}
if (req.query.exclude_reblogs === "true") {
baseFilter.type = { $ne: "boost" };
}
if (req.query.pinned === "true") {
baseFilter.pinned = true;
}
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
max_id: req.query.max_id,
min_id: req.query.min_id,
since_id: req.query.since_id,
});
let items = await collections.ap_timeline
.find(filter)
.sort(sort)
.limit(limit)
.toArray(); .toArray();
for (const f of follower) {
if (remoteActorId(f.actorUrl) === id) { if (reverse) {
return res.json( items.reverse();
}
// Load interaction state if authenticated
let favouritedIds = new Set();
let rebloggedIds = new Set();
let bookmarkedIds = new Set();
if (req.mastodonToken && collections.ap_interactions) {
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
if (lookupUrls.length > 0) {
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } })
.toArray();
for (const ix of interactions) {
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
}
}
}
const statuses = items.map((item) =>
serializeStatus(item, {
baseUrl,
favouritedIds,
rebloggedIds,
bookmarkedIds,
pinnedIds: new Set(),
}),
);
setPaginationHeaders(res, req, items, limit);
res.json(statuses);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
const profile = await collections.ap_profile.findOne({});
// Only serve followers for the local account
if (!profile || profile._id.toString() !== id) {
return res.json([]);
}
const followers = await collections.ap_followers
.find({})
.limit(limit)
.toArray();
const accounts = followers.map((f) =>
serializeAccount( serializeAccount(
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle }, { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
{ baseUrl }, { baseUrl },
), ),
); );
res.json(accounts);
} catch (error) {
next(error);
} }
});
// ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
const profile = await collections.ap_profile.findOne({});
// Only serve following for the local account
if (!profile || profile._id.toString() !== id) {
return res.json([]);
} }
const following = await collections.ap_following const following = await collections.ap_following
.find({}) .find({})
.limit(limit)
.toArray(); .toArray();
for (const f of following) {
if (remoteActorId(f.actorUrl) === id) { const accounts = following.map((f) =>
return res.json(
serializeAccount( serializeAccount(
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle }, { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
{ baseUrl }, { baseUrl },
), ),
); );
}
}
// Try timeline authors res.json(accounts);
const timelineItem = await collections.ap_timeline.findOne({
$expr: { $ne: [{ $type: "$author.url" }, "missing"] },
});
// For now, if not found in known actors, return 404
return res.status(404).json({ error: "Record not found" });
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -546,7 +721,81 @@ async function resolveActorUrl(id, collections) {
} }
} }
// Check timeline authors
const timelineItems = await collections.ap_timeline
.find({ "author.url": { $exists: true } })
.project({ "author.url": 1 })
.toArray();
const seenUrls = new Set();
for (const item of timelineItems) {
const authorUrl = item.author?.url;
if (!authorUrl || seenUrls.has(authorUrl)) continue;
seenUrls.add(authorUrl);
if (remoteActorId(authorUrl) === id) {
return authorUrl;
}
}
return null; return null;
} }
/**
* Resolve an account ID to both actor data and URL.
* Returns { actor, actorUrl } or { actor: null, actorUrl: null }.
*/
async function resolveActorData(id, collections) {
// Check followers — pass through all stored fields for richer serialization
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {
if (remoteActorId(f.actorUrl) === id) {
return {
actor: {
name: f.name,
url: f.actorUrl,
photo: f.avatar,
handle: f.handle,
bannerUrl: f.banner || "",
},
actorUrl: f.actorUrl,
};
}
}
// Check following — pass through all stored fields
const following = await collections.ap_following.find({}).toArray();
for (const f of following) {
if (remoteActorId(f.actorUrl) === id) {
return {
actor: {
name: f.name,
url: f.actorUrl,
photo: f.avatar,
handle: f.handle,
bannerUrl: f.banner || "",
},
actorUrl: f.actorUrl,
};
}
}
// Check timeline authors
const timelineItems = await collections.ap_timeline
.find({ "author.url": { $exists: true } })
.project({ author: 1 })
.toArray();
const seenUrls = new Set();
for (const item of timelineItems) {
const authorUrl = item.author?.url;
if (!authorUrl || seenUrls.has(authorUrl)) continue;
seenUrls.add(authorUrl);
if (remoteActorId(authorUrl) === id) {
return { actor: item.author, actorUrl: authorUrl };
}
}
return { actor: null, actorUrl: null };
}
export default router; export default router;
+12
View File
@@ -7,6 +7,7 @@ import express from "express";
import { serializeStatus } from "../entities/status.js"; import { serializeStatus } from "../entities/status.js";
import { serializeAccount } from "../entities/account.js"; import { serializeAccount } from "../entities/account.js";
import { parseLimit } from "../helpers/pagination.js"; import { parseLimit } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
@@ -21,6 +22,9 @@ router.get("/api/v2/search", async (req, res, next) => {
const limit = parseLimit(req.query.limit); const limit = parseLimit(req.query.limit);
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0); const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
const resolve = req.query.resolve === "true";
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
if (!query) { if (!query) {
return res.json({ accounts: [], statuses: [], hashtags: [] }); return res.json({ accounts: [], statuses: [], hashtags: [] });
} }
@@ -75,6 +79,14 @@ router.get("/api/v2/search", async (req, res, next) => {
} }
if (results.accounts.length >= limit) break; if (results.accounts.length >= limit) break;
} }
// If no local results and resolve=true, try remote lookup
if (results.accounts.length === 0 && resolve && query.includes("@")) {
const resolved = await resolveRemoteAccount(query, pluginOptions, baseUrl);
if (resolved) {
results.accounts.push(resolved);
}
}
} }
// ─── Status search ─────────────────────────────────────────────────── // ─── Status search ───────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.6.3", "version": "3.6.4",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",