From cfe35b28e834e22d15358ec6a061adc30fe65363 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:24:35 +0200 Subject: [PATCH] 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 --- index.js | 4 ++++ lib/mastodon/helpers/resolve-account.js | 14 +++++++++++++- lib/mastodon/routes/accounts.js | 9 ++++++++- lib/mastodon/routes/search.js | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index ebdb9cc..a06f206 100644 --- a/index.js +++ b/index.js @@ -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"); }, diff --git a/lib/mastodon/helpers/resolve-account.js b/lib/mastodon/helpers/resolve-account.js index c8379fb..25a3023 100644 --- a/lib/mastodon/helpers/resolve-account.js +++ b/lib/mastodon/helpers/resolve-account.js @@ -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} 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); diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index a8b766e..81951b7 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -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) { diff --git a/lib/mastodon/routes/search.js b/lib/mastodon/routes/search.js index 9f41887..6c7ebe7 100644 --- a/lib/mastodon/routes/search.js +++ b/lib/mastodon/routes/search.js @@ -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); }