fix: robust author resolution for like/boost with URL pattern fallback

When lookupObject fails (Authorized Fetch, network issues) and the post
isn't in ap_timeline, likes returned 404 "Could not resolve post author".

Adds shared resolveAuthor() with 3 strategies:
1. lookupObject on post URL → getAttributedTo
2. Timeline + notifications DB lookup
3. Extract author from URL pattern (/users/NAME/, /@NAME/)

Refactors like, unlike, boost controllers to use the shared helper.
This commit is contained in:
Ricardo
2026-02-22 21:33:45 +01:00
parent 77aad65947
commit bd07edefbb
4 changed files with 203 additions and 112 deletions
+14 -15
View File
@@ -4,6 +4,7 @@
*/ */
import { validateToken } from "../csrf.js"; import { validateToken } from "../csrf.js";
import { resolveAuthor } from "../resolve-author.js";
/** /**
* POST /admin/reader/boost — send an Announce activity to followers. * POST /admin/reader/boost — send an Announce activity to followers.
@@ -66,40 +67,38 @@ export function boostController(mountPath, plugin) {
orderingKey: url, orderingKey: url,
}); });
// Also send to the original post author (signed request for Authorized Fetch) // Also send directly to the original post author
try {
const documentLoader = await ctx.getDocumentLoader({ const documentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,
}); });
const remoteObject = await ctx.lookupObject(new URL(url), { const { application } = request.app.locals;
const recipient = await resolveAuthor(
url,
ctx,
documentLoader, documentLoader,
}); application?.collections,
);
if (
remoteObject &&
typeof remoteObject.getAttributedTo === "function"
) {
const author = await remoteObject.getAttributedTo({ documentLoader });
const recipient = Array.isArray(author) ? author[0] : author;
if (recipient) { if (recipient) {
try {
await ctx.sendActivity( await ctx.sendActivity(
{ identifier: handle }, { identifier: handle },
recipient, recipient,
announce, announce,
{ orderingKey: url }, { orderingKey: url },
); );
} console.info(
} `[ActivityPub] Sent boost directly to ${recipient.id?.href || "author"}`,
);
} catch (error) { } catch (error) {
console.warn( console.warn(
`[ActivityPub] lookupObject failed for ${url} (boost):`, `[ActivityPub] Direct boost delivery to author failed:`,
error.message, error.message,
); );
} }
}
// Track the interaction // Track the interaction
const { application } = request.app.locals;
const interactions = application?.collections?.get("ap_interactions"); const interactions = application?.collections?.get("ap_interactions");
if (interactions) { if (interactions) {
+10 -74
View File
@@ -4,6 +4,7 @@
*/ */
import { validateToken } from "../csrf.js"; import { validateToken } from "../csrf.js";
import { resolveAuthor } from "../resolve-author.js";
/** /**
* POST /admin/reader/like — send a Like activity to the post author. * POST /admin/reader/like — send a Like activity to the post author.
@@ -43,50 +44,17 @@ export function likeController(mountPath, plugin) {
{ handle, publicationUrl: plugin._publicationUrl }, { handle, publicationUrl: plugin._publicationUrl },
); );
// Use authenticated document loader for servers requiring Authorized Fetch
const documentLoader = await ctx.getDocumentLoader({ const documentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,
}); });
// Resolve author for delivery — try multiple strategies
let recipient = null;
// Strategy 1: Look up remote post via Fedify (signed request)
try {
const remoteObject = await ctx.lookupObject(new URL(url), {
documentLoader,
});
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo({ documentLoader });
recipient = Array.isArray(author) ? author[0] : author;
}
} catch (error) {
console.warn(
`[ActivityPub] lookupObject failed for ${url}:`,
error.message,
);
}
// Strategy 2: Use author URL from our timeline (already stored)
// Note: Timeline items store both uid (canonical AP URL) and url (display URL).
// The card passes the display URL, so we search by both fields.
if (!recipient) {
const { application } = request.app.locals; const { application } = request.app.locals;
const ap_timeline = application?.collections?.get("ap_timeline"); const recipient = await resolveAuthor(
const timelineItem = ap_timeline url,
? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] }) ctx,
: null;
const authorUrl = timelineItem?.author?.url;
if (authorUrl) {
try {
recipient = await ctx.lookupObject(new URL(authorUrl), {
documentLoader, documentLoader,
}); application?.collections,
} catch { );
// Could not resolve author actor either
}
}
if (!recipient) { if (!recipient) {
return response.status(404).json({ return response.status(404).json({
@@ -94,7 +62,6 @@ export function likeController(mountPath, plugin) {
error: "Could not resolve post author", error: "Could not resolve post author",
}); });
} }
}
// Generate a unique activity ID // Generate a unique activity ID
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
@@ -113,7 +80,6 @@ export function likeController(mountPath, plugin) {
}); });
// Track the interaction for undo // Track the interaction for undo
const { application } = request.app.locals;
const interactions = application?.collections?.get("ap_interactions"); const interactions = application?.collections?.get("ap_interactions");
if (interactions) { if (interactions) {
@@ -200,46 +166,16 @@ export function unlikeController(mountPath, plugin) {
{ handle, publicationUrl: plugin._publicationUrl }, { handle, publicationUrl: plugin._publicationUrl },
); );
// Use authenticated document loader for servers requiring Authorized Fetch
const documentLoader = await ctx.getDocumentLoader({ const documentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,
}); });
// Resolve the recipient — try remote first, then timeline fallback const recipient = await resolveAuthor(
let recipient = null; url,
ctx,
try {
const remoteObject = await ctx.lookupObject(new URL(url), {
documentLoader, documentLoader,
}); application?.collections,
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo({ documentLoader });
recipient = Array.isArray(author) ? author[0] : author;
}
} catch (error) {
console.warn(
`[ActivityPub] lookupObject failed for ${url} (unlike):`,
error.message,
); );
}
if (!recipient) {
const ap_timeline = application?.collections?.get("ap_timeline");
const timelineItem = ap_timeline
? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
: null;
const authorUrl = timelineItem?.author?.url;
if (authorUrl) {
try {
recipient = await ctx.lookupObject(new URL(authorUrl), {
documentLoader,
});
} catch {
// Could not resolve — will proceed to cleanup
}
}
}
if (!recipient) { if (!recipient) {
// Clean up the local record even if we can't send Undo // Clean up the local record even if we can't send Undo
+156
View File
@@ -0,0 +1,156 @@
/**
* Multi-strategy author resolution for interaction delivery.
*
* Resolves a post URL to the author's Actor object so that Like, Announce,
* and other activities can be delivered to the correct inbox.
*
* Strategies (tried in order):
* 1. lookupObject on post URL → getAttributedTo
* 2. Timeline/notification DB lookup → lookupObject on stored author URL
* 3. Extract author URL from post URL pattern → lookupObject
*/
/**
* Extract a probable author URL from a post URL using common fediverse patterns.
*
* @param {string} postUrl - The post URL
* @returns {string|null} - Author URL or null
*
* Patterns matched:
* https://instance/users/USERNAME/statuses/ID → https://instance/users/USERNAME
* https://instance/@USERNAME/ID → https://instance/users/USERNAME
* https://instance/p/USERNAME/ID → https://instance/users/USERNAME (Pixelfed)
* https://instance/notice/ID → null (no username in URL)
*/
export function extractAuthorUrl(postUrl) {
try {
const parsed = new URL(postUrl);
const path = parsed.pathname;
// /users/USERNAME/statuses/ID — Mastodon, GoToSocial, Akkoma canonical
const usersMatch = path.match(/^\/users\/([^/]+)\//);
if (usersMatch) {
return `${parsed.origin}/users/${usersMatch[1]}`;
}
// /@USERNAME/ID — Mastodon display URL
const atMatch = path.match(/^\/@([^/]+)\/\d/);
if (atMatch) {
return `${parsed.origin}/users/${atMatch[1]}`;
}
// /p/USERNAME/ID — Pixelfed
const pixelfedMatch = path.match(/^\/p\/([^/]+)\/\d/);
if (pixelfedMatch) {
return `${parsed.origin}/users/${pixelfedMatch[1]}`;
}
return null;
} catch {
return null;
}
}
/**
* Resolve the author Actor for a given post URL.
*
* @param {string} postUrl - The post URL to resolve the author for
* @param {object} ctx - Fedify context
* @param {object} documentLoader - Authenticated document loader
* @param {object} [collections] - Optional MongoDB collections map (application.collections)
* @returns {Promise<object|null>} - Fedify Actor object or null
*/
export async function resolveAuthor(
postUrl,
ctx,
documentLoader,
collections,
) {
// Strategy 1: Look up remote post via Fedify (signed request)
try {
const remoteObject = await ctx.lookupObject(new URL(postUrl), {
documentLoader,
});
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo({ documentLoader });
const recipient = Array.isArray(author) ? author[0] : author;
if (recipient) {
console.info(
`[ActivityPub] Resolved author via lookupObject for ${postUrl}`,
);
return recipient;
}
}
} catch (error) {
console.warn(
`[ActivityPub] lookupObject failed for ${postUrl}:`,
error.message,
);
}
// Strategy 2: Use author URL from timeline or notifications
if (collections) {
const ap_timeline = collections.get("ap_timeline");
const ap_notifications = collections.get("ap_notifications");
// Search timeline by both uid (canonical) and url (display)
let authorUrl = null;
if (ap_timeline) {
const item = await ap_timeline.findOne({
$or: [{ uid: postUrl }, { url: postUrl }],
});
authorUrl = item?.author?.url;
}
// Fall back to notifications if not in timeline
if (!authorUrl && ap_notifications) {
const notif = await ap_notifications.findOne({
$or: [{ objectUrl: postUrl }, { targetUrl: postUrl }],
});
authorUrl = notif?.actorUrl;
}
if (authorUrl) {
try {
const actor = await ctx.lookupObject(new URL(authorUrl), {
documentLoader,
});
if (actor) {
console.info(
`[ActivityPub] Resolved author via DB for ${postUrl}${authorUrl}`,
);
return actor;
}
} catch (error) {
console.warn(
`[ActivityPub] lookupObject failed for author ${authorUrl}:`,
error.message,
);
}
}
}
// Strategy 3: Extract author URL from post URL pattern
const extractedUrl = extractAuthorUrl(postUrl);
if (extractedUrl) {
try {
const actor = await ctx.lookupObject(new URL(extractedUrl), {
documentLoader,
});
if (actor) {
console.info(
`[ActivityPub] Resolved author via URL pattern for ${postUrl}${extractedUrl}`,
);
return actor;
}
} catch (error) {
console.warn(
`[ActivityPub] lookupObject failed for extracted author ${extractedUrl}:`,
error.message,
);
}
}
console.warn(`[ActivityPub] All author resolution strategies failed for ${postUrl}`);
return null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.0.7", "version": "2.0.8",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",