From bd07edefbbbd8850a8454076980b699f5ca4d178 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 22 Feb 2026 21:33:45 +0100 Subject: [PATCH] fix: robust author resolution for like/boost with URL pattern fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/controllers/interactions-boost.js | 57 +++++----- lib/controllers/interactions-like.js | 100 +++-------------- lib/resolve-author.js | 156 ++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 203 insertions(+), 112 deletions(-) create mode 100644 lib/resolve-author.js diff --git a/lib/controllers/interactions-boost.js b/lib/controllers/interactions-boost.js index 15b5465..17edb46 100644 --- a/lib/controllers/interactions-boost.js +++ b/lib/controllers/interactions-boost.js @@ -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) { diff --git a/lib/controllers/interactions-like.js b/lib/controllers/interactions-like.js index 942f3cc..723b5f4 100644 --- a/lib/controllers/interactions-like.js +++ b/lib/controllers/interactions-like.js @@ -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 diff --git a/lib/resolve-author.js b/lib/resolve-author.js new file mode 100644 index 0000000..9d45d4c --- /dev/null +++ b/lib/resolve-author.js @@ -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} - 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; +} diff --git a/package.json b/package.json index 1800bff..d1d7f01 100644 --- a/package.json +++ b/package.json @@ -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",