Files
svemagie c64b7877a2 fix: populate header image in profile lookups and timeline enrichment
- accounts/lookup: add bannerUrl to ap_following path (was missing, follower path was correct)
- account-cache: store headerUrl alongside counts and avatarUrl
- resolve-account: pass headerUrl to cacheAccountStats
- enrich-accounts: apply cached.headerUrl to account.header/header_static

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:12:29 +02:00

162 lines
6.2 KiB
JavaScript

/**
* 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";
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, collections = null) {
const { federation, handle, publicationUrl } = pluginOptions;
if (!federation) return null;
try {
const ctx = federation.createContext(
new URL(publicationUrl),
{ handle, publicationUrl },
);
// Determine lookup URI.
// acct:user@domain — kept as a string; Fedify resolves it via WebFinger.
// HTTP URLs — converted to URL objects for type-correct AP object fetch.
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 = new URL(acct);
} else {
return null;
}
// Use signed→unsigned fallback so servers rejecting signed GETs still resolve
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
// Timeout guard: cap actor fetch at 8 s so hung lookups fail fast.
const _aLookupTimeout = (p, ms = 8000) => { const t = new Promise((_, rej) => setTimeout(() => rej(new Error("actor lookup timeout")), ms)); p.catch(() => {}); return Promise.race([p, t]); };
const actor = await _aLookupTimeout(lookupWithSecurity(ctx, actorUri, { documentLoader })).catch(err => { console.warn(`[Mastodon API] Actor lookup failed for ${acct}: ${err.message}`); return null; });
if (!actor) { console.warn(`[Mastodon API] lookupWithSecurity returned null for ${acct}`); 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 */ }
// Get collection counts (followers, following, outbox) — with 5 s timeout each
const withTimeout = (promise, ms = 5000) =>
Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
let followersCount = 0;
let followingCount = 0;
let statusesCount = 0;
try {
const followers = await withTimeout(actor.getFollowers());
if (followers?.totalItems != null) followersCount = followers.totalItems;
} catch { /* ignore */ }
try {
const following = await withTimeout(actor.getFollowing());
if (following?.totalItems != null) followingCount = following.totalItems;
} catch { /* ignore */ }
try {
const outbox = await withTimeout(actor.getOutbox());
if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
} catch { /* ignore */ }
// Get published/created date — normalize to UTC ISO so clients display it correctly.
// Temporal.Instant.toString() preserves the original timezone offset;
// passing through new Date() converts to "YYYY-MM-DDTHH:mm:ss.sssZ".
let published = null;
if (actor.published) {
try {
published = new Date(String(actor.published)).toISOString();
} catch { /* ignore unparseable dates */ }
}
// Profile fields from attachments
const fields = [];
try {
for await (const attachment of actor.getAttachments()) {
if (attachment?.name) {
fields.push({
name: attachment.name?.toString() || "",
value: attachment.value?.toString() || "",
});
}
}
} catch { /* ignore */ }
const account = serializeAccount(
{
name,
url: actorUrl,
photo: avatarUrl,
handle: `@${username}@${domain}`,
summary,
image: headerUrl,
bot: actor.constructor?.name === "Service" || actor.constructor?.name === "Application",
attachments: fields.length > 0 ? fields : undefined,
createdAt: published || undefined,
},
{ baseUrl },
);
// Override counts with real data from AP collections
account.followers_count = followersCount;
account.following_count = followingCount;
account.statuses_count = statusesCount;
// Cache stats (+ avatar + header) so embedded account objects in statuses can use them
cacheAccountStats(actorUrl, {
followersCount,
followingCount,
statusesCount,
createdAt: published || undefined,
avatarUrl: avatarUrl || undefined,
headerUrl: headerUrl || 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);
return null;
}
}