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 // Filters and filter keywords
Indiekit.addCollection("ap_filters"); Indiekit.addCollection("ap_filters");
Indiekit.addCollection("ap_filter_keywords"); 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) // Plugin settings (single document, admin UI at /admin/settings)
Indiekit.addCollection("ap_settings"); Indiekit.addCollection("ap_settings");
@@ -1253,6 +1255,8 @@ export default class ActivityPubEndpoint {
// Filters and filter keywords // Filters and filter keywords
ap_filters: indiekitCollections.get("ap_filters"), ap_filters: indiekitCollections.get("ap_filters"),
ap_filter_keywords: indiekitCollections.get("ap_filter_keywords"), 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() { get posts() {
return indiekitCollections.get("posts"); return indiekitCollections.get("posts");
}, },
+13 -1
View File
@@ -6,15 +6,17 @@
*/ */
import { serializeAccount } from "../entities/account.js"; import { serializeAccount } from "../entities/account.js";
import { cacheAccountStats } from "./account-cache.js"; import { cacheAccountStats } from "./account-cache.js";
import { remoteActorId } from "./id-mapping.js";
import { lookupWithSecurity } from "../../lookup-helpers.js"; import { lookupWithSecurity } from "../../lookup-helpers.js";
/** /**
* @param {string} acct - Account identifier (user@domain or URL) * @param {string} acct - Account identifier (user@domain or URL)
* @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl * @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl
* @param {string} baseUrl - Server base URL * @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 * @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; const { federation, handle, publicationUrl } = pluginOptions;
if (!federation) return null; if (!federation) return null;
@@ -138,6 +140,16 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
avatarUrl: avatarUrl || undefined, 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; return account;
} catch (error) { } catch (error) {
console.warn(`[Mastodon API] Remote account resolution failed for ${acct}:`, error.message); 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) // Resolve remotely via federation (WebFinger + actor fetch)
const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl); const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl, collections);
if (resolved) { if (resolved) {
return res.json(resolved); return res.json(resolved);
} }
@@ -375,6 +375,7 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac
actorUrl, actorUrl,
pluginOptions, pluginOptions,
baseUrl, baseUrl,
collections,
); );
if (remoteAccount) { if (remoteAccount) {
return res.json(remoteAccount); return res.json(remoteAccount);
@@ -816,6 +817,12 @@ async function resolveActorUrl(id, collections) {
const cachedUrl = getActorUrlFromId(id); const cachedUrl = getActorUrlFromId(id);
if (cachedUrl) return cachedUrl; 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 // Check followers
const followers = await collections.ap_followers.find({}).toArray(); const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) { 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 no local results and resolve=true, try remote lookup
if (results.accounts.length === 0 && resolve && query.includes("@")) { 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) { if (resolved) {
results.accounts.push(resolved); results.accounts.push(resolved);
} }