From c2920cafd81c875d2351f6bf99eed435da8e3ce4 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 23 Mar 2026 07:56:34 +0100 Subject: [PATCH] fix: raw signed fetch fallback for author resolution Servers like wafrn return AP JSON without @context, causing Fedify's JSON-LD processor to reject the document. Strategy 1b in resolveAuthor does a direct signed GET, extracts attributedTo/actor from plain JSON, then resolves the actor via lookupWithSecurity. Also: _loadRsaPrivateKey now imports with extractable=true (required by Fedify's signRequest), and loadRsaKey is wired through to all Mastodon API interaction helpers. --- index.js | 3 +- lib/controllers/interactions-boost.js | 5 +++ lib/controllers/interactions-like.js | 10 ++++++ lib/mastodon/helpers/interactions.js | 24 ++++++++++---- lib/mastodon/routes/statuses.js | 1 + lib/resolve-author.js | 46 +++++++++++++++++++++++++++ package.json | 2 +- 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 3e9ddcf..a6494dc 100644 --- a/index.js +++ b/index.js @@ -744,7 +744,7 @@ export default class ActivityPubEndpoint { "pkcs8", Buffer.from(pemBody, "base64"), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, + true, ["sign"], ); } catch (error) { @@ -1589,6 +1589,7 @@ export default class ActivityPubEndpoint { federation: this._federation, followActor: (url, info) => pluginRef.followActor(url, info), unfollowActor: (url) => pluginRef.unfollowActor(url), + loadRsaKey: () => pluginRef._loadRsaPrivateKey(), }, }); Indiekit.addEndpoint({ diff --git a/lib/controllers/interactions-boost.js b/lib/controllers/interactions-boost.js index b8ea98e..c564cf5 100644 --- a/lib/controllers/interactions-boost.js +++ b/lib/controllers/interactions-boost.js @@ -72,11 +72,16 @@ export function boostController(mountPath, plugin) { identifier: handle, }); const { application } = request.app.locals; + const rsaKey = await plugin._loadRsaPrivateKey(); const recipient = await resolveAuthor( url, ctx, documentLoader, application?.collections, + { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }, ); if (recipient) { diff --git a/lib/controllers/interactions-like.js b/lib/controllers/interactions-like.js index 723b5f4..1fe5707 100644 --- a/lib/controllers/interactions-like.js +++ b/lib/controllers/interactions-like.js @@ -49,11 +49,16 @@ export function likeController(mountPath, plugin) { }); const { application } = request.app.locals; + const rsaKey = await plugin._loadRsaPrivateKey(); const recipient = await resolveAuthor( url, ctx, documentLoader, application?.collections, + { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }, ); if (!recipient) { @@ -170,11 +175,16 @@ export function unlikeController(mountPath, plugin) { identifier: handle, }); + const rsaKey2 = await plugin._loadRsaPrivateKey(); const recipient = await resolveAuthor( url, ctx, documentLoader, application?.collections, + { + privateKey: rsaKey2, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }, ); if (!recipient) { diff --git a/lib/mastodon/helpers/interactions.js b/lib/mastodon/helpers/interactions.js index 3072b02..e478bb2 100644 --- a/lib/mastodon/helpers/interactions.js +++ b/lib/mastodon/helpers/interactions.js @@ -22,7 +22,7 @@ import { resolveAuthor } from "../../resolve-author.js"; * @param {object} params.interactions - ap_interactions collection * @returns {Promise<{ activityId: string }>} */ -export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { +export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) { const { Like } = await import("@fedify/fedify/vocab"); const ctx = federation.createContext( new URL(publicationUrl), @@ -30,7 +30,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl, ); const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + const rsaKey = loadRsaKey ? await loadRsaKey() : null; + const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }); const uuid = crypto.randomUUID(); const baseUrl = publicationUrl.replace(/\/$/, ""); @@ -79,7 +83,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl, * @param {object} params.interactions - ap_interactions collection * @returns {Promise} */ -export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { +export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) { const existing = interactions ? await interactions.findOne({ objectUrl: targetUrl, type: "like" }) : null; @@ -95,7 +99,11 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl ); const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + const rsaKey = loadRsaKey ? await loadRsaKey() : null; + const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }); if (recipient) { const like = new Like({ @@ -131,7 +139,7 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl * @param {object} params.interactions - ap_interactions collection * @returns {Promise<{ activityId: string }>} */ -export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { +export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) { const { Announce } = await import("@fedify/fedify/vocab"); const ctx = federation.createContext( new URL(publicationUrl), @@ -162,7 +170,11 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl, // Also send directly to the original post author const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + const rsaKey = loadRsaKey ? await loadRsaKey() : null; + const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }); if (recipient) { try { await ctx.sendActivity({ identifier: handle }, recipient, announce, { diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 47a6789..86af005 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -614,6 +614,7 @@ function getFederationOpts(req) { handle: pluginOptions.handle || "user", publicationUrl: pluginOptions.publicationUrl, collections: req.app.locals.mastodonCollections, + loadRsaKey: pluginOptions.loadRsaKey, }; } diff --git a/lib/resolve-author.js b/lib/resolve-author.js index 051a5ef..f77cd70 100644 --- a/lib/resolve-author.js +++ b/lib/resolve-author.js @@ -6,6 +6,8 @@ * * Strategies (tried in order): * 1. lookupObject on post URL → getAttributedTo + * 1b. Raw signed fetch fallback (for servers like wafrn that return + * non-standard JSON-LD that Fedify can't parse) * 2. Timeline/notification DB lookup → lookupObject on stored author URL * 3. Extract author URL from post URL pattern → lookupObject */ @@ -60,6 +62,9 @@ export function extractAuthorUrl(postUrl) { * @param {object} ctx - Fedify context * @param {object} documentLoader - Authenticated document loader * @param {object} [collections] - Optional MongoDB collections map (application.collections) + * @param {object} [options] - Additional options + * @param {CryptoKey} [options.privateKey] - RSA private key for raw signed fetch fallback + * @param {string} [options.keyId] - Key ID for HTTP Signature (e.g. "...#main-key") * @returns {Promise} - Fedify Actor object or null */ export async function resolveAuthor( @@ -67,6 +72,7 @@ export async function resolveAuthor( ctx, documentLoader, collections, + options = {}, ) { // Strategy 1: Look up remote post via Fedify (signed request) try { @@ -90,6 +96,46 @@ export async function resolveAuthor( ); } + // Strategy 1b: Raw signed fetch fallback + // Some servers (e.g. wafrn) return AP JSON without @context, which Fedify's + // JSON-LD processor rejects. A raw fetch can still extract attributedTo/actor. + if (options.privateKey && options.keyId) { + try { + const { signRequest } = await import("@fedify/fedify/sig"); + const request = new Request(postUrl, { + method: "GET", + headers: { Accept: "application/activity+json" }, + }); + const signed = await signRequest(request, options.privateKey, new URL(options.keyId), { + spec: "draft-cavage-http-signatures-12", + }); + const res = await fetch(signed, { redirect: "follow" }); + if (res.ok) { + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("json")) { + const json = await res.json(); + const authorUrl = json.attributedTo || json.actor; + if (authorUrl && typeof authorUrl === "string") { + const actor = await lookupWithSecurity(ctx, new URL(authorUrl), { + documentLoader, + }); + if (actor) { + console.info( + `[ActivityPub] Resolved author via raw fetch for ${postUrl} → ${authorUrl}`, + ); + return actor; + } + } + } + } + } catch (error) { + console.warn( + `[ActivityPub] Raw fetch fallback failed for ${postUrl}:`, + error.message, + ); + } + } + // Strategy 2: Use author URL from timeline or notifications if (collections) { const ap_timeline = collections.get("ap_timeline"); diff --git a/package.json b/package.json index 224bed5..f29b3e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.8.4", + "version": "3.8.5", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",