diff --git a/index.js b/index.js index d1eb53a..064256e 100644 --- a/index.js +++ b/index.js @@ -856,7 +856,7 @@ export default class ActivityPubEndpoint { "pkcs8", Buffer.from(pemBody, "base64"), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, + true, ["sign"], ); } catch (error) { @@ -1827,6 +1827,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 f8b702c..a285dab 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), @@ -32,10 +32,14 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl, const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); // resolveAuthor makes up to 3 signed HTTP requests to the remote server. // Cap at 5 s so a slow/unreachable remote never blocks the client response. + const rsaKey = loadRsaKey ? await loadRsaKey() : null; let recipient = null; try { recipient = await Promise.race([ - resolveAuthor(targetUrl, ctx, documentLoader, collections), + resolveAuthor(targetUrl, ctx, documentLoader, collections, { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }), new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)), ]); } catch { /* skip AP delivery — interaction is still recorded locally */ } @@ -87,7 +91,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; @@ -103,10 +107,14 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl ); const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const rsaKey = loadRsaKey ? await loadRsaKey() : null; let recipient = null; try { recipient = await Promise.race([ - resolveAuthor(targetUrl, ctx, documentLoader, collections), + resolveAuthor(targetUrl, ctx, documentLoader, collections, { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }), new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)), ]); } catch { /* skip AP delivery */ } @@ -145,7 +153,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), @@ -176,10 +184,14 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl, // Also send directly to the original post author (best-effort, 5 s cap) const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const rsaKey = loadRsaKey ? await loadRsaKey() : null; let recipient = null; try { recipient = await Promise.race([ - resolveAuthor(targetUrl, ctx, documentLoader, collections), + resolveAuthor(targetUrl, ctx, documentLoader, collections, { + privateKey: rsaKey, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + }), new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)), ]); } catch { /* skip author delivery — follower delivery already happened */ } diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 6a34367..6fec78c 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -793,6 +793,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 cfdd9f6..003a436 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 = typeof collections.get === "function" ? collections.get("ap_timeline") : collections.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",