fix: persist actor URL to MongoDB so follow survives server restarts

resolveActorUrl() relied on an in-memory idToUrl cache that is cleared
on every server restart. Accounts discovered via search or profile lookup
but not yet in followers/following/timeline would fail with 404 when
attempting to follow after a restart.

Fix: add ap_actor_cache MongoDB collection. resolveRemoteAccount() now
persists { _id: hash(actorUrl), actorUrl } on every successful remote
resolution (fire-and-forget, non-fatal). resolveActorUrl() checks this
collection after the in-memory cache, before falling through to DB scans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-04-09 16:24:35 +02:00
parent c8ca9914f8
commit cfe35b28e8
4 changed files with 26 additions and 3 deletions
+4
View File
@@ -1203,6 +1203,8 @@ export default class ActivityPubEndpoint {
// Filters and filter keywords
Indiekit.addCollection("ap_filters");
Indiekit.addCollection("ap_filter_keywords");
// Persistent actor URL cache (survives server restarts; used by follow/unfollow)
Indiekit.addCollection("ap_actor_cache");
// Plugin settings (single document, admin UI at /admin/settings)
Indiekit.addCollection("ap_settings");
@@ -1253,6 +1255,8 @@ export default class ActivityPubEndpoint {
// Filters and filter keywords
ap_filters: indiekitCollections.get("ap_filters"),
ap_filter_keywords: indiekitCollections.get("ap_filter_keywords"),
// Persistent actor URL cache (survives server restarts)
ap_actor_cache: indiekitCollections.get("ap_actor_cache"),
get posts() {
return indiekitCollections.get("posts");
},
+13 -1
View File
@@ -6,15 +6,17 @@
*/
import { serializeAccount } from "../entities/account.js";
import { cacheAccountStats } from "./account-cache.js";
import { remoteActorId } from "./id-mapping.js";
import { lookupWithSecurity } from "../../lookup-helpers.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
* @param {object|null} collections - MongoDB collections (optional; if provided, persists actorUrl to ap_actor_cache)
* @returns {Promise<object|null>} Serialized Mastodon Account or null
*/
export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collections = null) {
const { federation, handle, publicationUrl } = pluginOptions;
if (!federation) return null;
@@ -138,6 +140,16 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
avatarUrl: avatarUrl || undefined,
});
// Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts
if (collections?.ap_actor_cache && actorUrl) {
const hashId = remoteActorId(actorUrl);
collections.ap_actor_cache.updateOne(
{ _id: hashId },
{ $set: { actorUrl, updatedAt: new Date() } },
{ upsert: true },
).catch(() => {}); // fire-and-forget, non-fatal
}
return account;
} catch (error) {
console.warn(`[Mastodon API] Remote account resolution failed for ${acct}:`, error.message);
+8 -1
View File
@@ -143,7 +143,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
}
// Resolve remotely via federation (WebFinger + actor fetch)
const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl);
const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl, collections);
if (resolved) {
return res.json(resolved);
}
@@ -375,6 +375,7 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac
actorUrl,
pluginOptions,
baseUrl,
collections,
);
if (remoteAccount) {
return res.json(remoteAccount);
@@ -816,6 +817,12 @@ async function resolveActorUrl(id, collections) {
const cachedUrl = getActorUrlFromId(id);
if (cachedUrl) return cachedUrl;
// Check persistent actor cache (survives server restarts)
if (collections.ap_actor_cache) {
const cached = await collections.ap_actor_cache.findOne({ _id: id });
if (cached?.actorUrl) return cached.actorUrl;
}
// Check followers
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {
+1 -1
View File
@@ -84,7 +84,7 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search")
// 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);
const resolved = await resolveRemoteAccount(query, pluginOptions, baseUrl, collections);
if (resolved) {
results.accounts.push(resolved);
}