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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+282
-33
@@ -6,7 +6,10 @@
|
||||
*/
|
||||
import express from "express";
|
||||
import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
|
||||
import { serializeStatus } from "../entities/status.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
|
||||
|
||||
@@ -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({
|
||||
$or: [
|
||||
{ handle: `@${bareAcct}` },
|
||||
@@ -108,12 +111,44 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
|
||||
if (follower) {
|
||||
return res.json(
|
||||
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 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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" });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -133,47 +168,187 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
|
||||
// Check if it's the local profile
|
||||
const profile = await collections.ap_profile.findOne({});
|
||||
if (profile && profile._id.toString() === id) {
|
||||
return res.json(
|
||||
serializeAccount(profile, { baseUrl, isLocal: true, handle }),
|
||||
);
|
||||
const [statuses, followers, following] = await Promise.all([
|
||||
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)
|
||||
// by checking if the deterministic hash matches
|
||||
const follower = await collections.ap_followers
|
||||
.find({})
|
||||
// Resolve remote actor from followers, following, or timeline
|
||||
const { actor, actorUrl } = await resolveActorData(id, collections);
|
||||
if (actor) {
|
||||
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();
|
||||
for (const f of follower) {
|
||||
if (remoteActorId(f.actorUrl) === id) {
|
||||
return res.json(
|
||||
serializeAccount(
|
||||
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
||||
{ baseUrl },
|
||||
),
|
||||
);
|
||||
|
||||
if (reverse) {
|
||||
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(
|
||||
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
|
||||
{ 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
|
||||
.find({})
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
for (const f of following) {
|
||||
if (remoteActorId(f.actorUrl) === id) {
|
||||
return res.json(
|
||||
serializeAccount(
|
||||
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
||||
{ baseUrl },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Try timeline authors
|
||||
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" });
|
||||
const accounts = following.map((f) =>
|
||||
serializeAccount(
|
||||
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle, bannerUrl: f.banner || "" },
|
||||
{ baseUrl },
|
||||
),
|
||||
);
|
||||
|
||||
res.json(accounts);
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import express from "express";
|
||||
import { serializeStatus } from "../entities/status.js";
|
||||
import { serializeAccount } from "../entities/account.js";
|
||||
import { parseLimit } from "../helpers/pagination.js";
|
||||
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
|
||||
|
||||
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 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) {
|
||||
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 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 ───────────────────────────────────────────────────
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
Reference in New Issue
Block a user