From 9f1287073b2bcdbc2e57ec5ad0ffbc9e172be61d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 11:49:12 +0100 Subject: [PATCH] feat: resolve remote profiles via WebFinger in Mastodon API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 @. --- lib/mastodon/helpers/resolve-account.js | 79 ++++++ lib/mastodon/routes/accounts.js | 315 +++++++++++++++++++++--- lib/mastodon/routes/search.js | 12 + package.json | 2 +- 4 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 lib/mastodon/helpers/resolve-account.js diff --git a/lib/mastodon/helpers/resolve-account.js b/lib/mastodon/helpers/resolve-account.js new file mode 100644 index 0000000..7a79687 --- /dev/null +++ b/lib/mastodon/helpers/resolve-account.js @@ -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} 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; + } +} diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 702dd39..5b6bdaf 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -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; diff --git a/lib/mastodon/routes/search.js b/lib/mastodon/routes/search.js index 03a47c5..6c4a259 100644 --- a/lib/mastodon/routes/search.js +++ b/lib/mastodon/routes/search.js @@ -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 ─────────────────────────────────────────────────── diff --git a/package.json b/package.json index 6681831..578f29b 100644 --- a/package.json +++ b/package.json @@ -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",