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:
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user