mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { validateToken } from "../csrf.js";
|
||||
import { resolveAuthor } from "../resolve-author.js";
|
||||
|
||||
/**
|
||||
* POST /admin/reader/boost — send an Announce activity to followers.
|
||||
@@ -66,40 +67,38 @@ export function boostController(mountPath, plugin) {
|
||||
orderingKey: url,
|
||||
});
|
||||
|
||||
// Also send to the original post author (signed request for Authorized Fetch)
|
||||
try {
|
||||
const documentLoader = await ctx.getDocumentLoader({
|
||||
identifier: handle,
|
||||
});
|
||||
const remoteObject = await ctx.lookupObject(new URL(url), {
|
||||
documentLoader,
|
||||
});
|
||||
// Also send directly to the original post author
|
||||
const documentLoader = await ctx.getDocumentLoader({
|
||||
identifier: handle,
|
||||
});
|
||||
const { application } = request.app.locals;
|
||||
const recipient = await resolveAuthor(
|
||||
url,
|
||||
ctx,
|
||||
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) {
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
recipient,
|
||||
announce,
|
||||
{ orderingKey: url },
|
||||
);
|
||||
}
|
||||
if (recipient) {
|
||||
try {
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
recipient,
|
||||
announce,
|
||||
{ orderingKey: url },
|
||||
);
|
||||
console.info(
|
||||
`[ActivityPub] Sent boost directly to ${recipient.id?.href || "author"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Direct boost delivery to author failed:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] lookupObject failed for ${url} (boost):`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
|
||||
// Track the interaction
|
||||
const { application } = request.app.locals;
|
||||
const interactions = application?.collections?.get("ap_interactions");
|
||||
|
||||
if (interactions) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { validateToken } from "../csrf.js";
|
||||
import { resolveAuthor } from "../resolve-author.js";
|
||||
|
||||
/**
|
||||
* POST /admin/reader/like — send a Like activity to the post author.
|
||||
@@ -43,57 +44,23 @@ export function likeController(mountPath, plugin) {
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
|
||||
// Use authenticated document loader for servers requiring Authorized Fetch
|
||||
const documentLoader = await ctx.getDocumentLoader({
|
||||
identifier: handle,
|
||||
});
|
||||
|
||||
// Resolve author for delivery — try multiple strategies
|
||||
let recipient = null;
|
||||
const { application } = request.app.locals;
|
||||
const recipient = await resolveAuthor(
|
||||
url,
|
||||
ctx,
|
||||
documentLoader,
|
||||
application?.collections,
|
||||
);
|
||||
|
||||
// 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 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 author actor either
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipient) {
|
||||
return response.status(404).json({
|
||||
success: false,
|
||||
error: "Could not resolve post author",
|
||||
});
|
||||
}
|
||||
return response.status(404).json({
|
||||
success: false,
|
||||
error: "Could not resolve post author",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a unique activity ID
|
||||
@@ -113,7 +80,6 @@ export function likeController(mountPath, plugin) {
|
||||
});
|
||||
|
||||
// Track the interaction for undo
|
||||
const { application } = request.app.locals;
|
||||
const interactions = application?.collections?.get("ap_interactions");
|
||||
|
||||
if (interactions) {
|
||||
@@ -200,46 +166,16 @@ export function unlikeController(mountPath, plugin) {
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
|
||||
// Use authenticated document loader for servers requiring Authorized Fetch
|
||||
const documentLoader = await ctx.getDocumentLoader({
|
||||
identifier: handle,
|
||||
});
|
||||
|
||||
// Resolve the recipient — try remote first, then timeline fallback
|
||||
let recipient = null;
|
||||
|
||||
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} (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
|
||||
}
|
||||
}
|
||||
}
|
||||
const recipient = await resolveAuthor(
|
||||
url,
|
||||
ctx,
|
||||
documentLoader,
|
||||
application?.collections,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
// Clean up the local record even if we can't send Undo
|
||||
|
||||
156
lib/resolve-author.js
Normal file
156
lib/resolve-author.js
Normal 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,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
Reference in New Issue
Block a user