mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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.
This commit is contained in:
3
index.js
3
index.js
@@ -744,7 +744,7 @@ export default class ActivityPubEndpoint {
|
|||||||
"pkcs8",
|
"pkcs8",
|
||||||
Buffer.from(pemBody, "base64"),
|
Buffer.from(pemBody, "base64"),
|
||||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||||
false,
|
true,
|
||||||
["sign"],
|
["sign"],
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1589,6 +1589,7 @@ export default class ActivityPubEndpoint {
|
|||||||
federation: this._federation,
|
federation: this._federation,
|
||||||
followActor: (url, info) => pluginRef.followActor(url, info),
|
followActor: (url, info) => pluginRef.followActor(url, info),
|
||||||
unfollowActor: (url) => pluginRef.unfollowActor(url),
|
unfollowActor: (url) => pluginRef.unfollowActor(url),
|
||||||
|
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Indiekit.addEndpoint({
|
Indiekit.addEndpoint({
|
||||||
|
|||||||
@@ -72,11 +72,16 @@ export function boostController(mountPath, plugin) {
|
|||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
const { application } = request.app.locals;
|
const { application } = request.app.locals;
|
||||||
|
const rsaKey = await plugin._loadRsaPrivateKey();
|
||||||
const recipient = await resolveAuthor(
|
const recipient = await resolveAuthor(
|
||||||
url,
|
url,
|
||||||
ctx,
|
ctx,
|
||||||
documentLoader,
|
documentLoader,
|
||||||
application?.collections,
|
application?.collections,
|
||||||
|
{
|
||||||
|
privateKey: rsaKey,
|
||||||
|
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (recipient) {
|
if (recipient) {
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ export function likeController(mountPath, plugin) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { application } = request.app.locals;
|
const { application } = request.app.locals;
|
||||||
|
const rsaKey = await plugin._loadRsaPrivateKey();
|
||||||
const recipient = await resolveAuthor(
|
const recipient = await resolveAuthor(
|
||||||
url,
|
url,
|
||||||
ctx,
|
ctx,
|
||||||
documentLoader,
|
documentLoader,
|
||||||
application?.collections,
|
application?.collections,
|
||||||
|
{
|
||||||
|
privateKey: rsaKey,
|
||||||
|
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@@ -170,11 +175,16 @@ export function unlikeController(mountPath, plugin) {
|
|||||||
identifier: handle,
|
identifier: handle,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rsaKey2 = await plugin._loadRsaPrivateKey();
|
||||||
const recipient = await resolveAuthor(
|
const recipient = await resolveAuthor(
|
||||||
url,
|
url,
|
||||||
ctx,
|
ctx,
|
||||||
documentLoader,
|
documentLoader,
|
||||||
application?.collections,
|
application?.collections,
|
||||||
|
{
|
||||||
|
privateKey: rsaKey2,
|
||||||
|
keyId: `${ctx.getActorUri(handle).href}#main-key`,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { resolveAuthor } from "../../resolve-author.js";
|
|||||||
* @param {object} params.interactions - ap_interactions collection
|
* @param {object} params.interactions - ap_interactions collection
|
||||||
* @returns {Promise<{ activityId: string }>}
|
* @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 { Like } = await import("@fedify/fedify/vocab");
|
||||||
const ctx = federation.createContext(
|
const ctx = federation.createContext(
|
||||||
new URL(publicationUrl),
|
new URL(publicationUrl),
|
||||||
@@ -30,7 +30,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|||||||
);
|
);
|
||||||
|
|
||||||
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
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 uuid = crypto.randomUUID();
|
||||||
const baseUrl = publicationUrl.replace(/\/$/, "");
|
const baseUrl = publicationUrl.replace(/\/$/, "");
|
||||||
@@ -79,7 +83,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
|||||||
* @param {object} params.interactions - ap_interactions collection
|
* @param {object} params.interactions - ap_interactions collection
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
|
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
|
||||||
const existing = interactions
|
const existing = interactions
|
||||||
? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
|
? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
|
||||||
: null;
|
: null;
|
||||||
@@ -95,7 +99,11 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
|
|||||||
);
|
);
|
||||||
|
|
||||||
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
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) {
|
if (recipient) {
|
||||||
const like = new Like({
|
const like = new Like({
|
||||||
@@ -131,7 +139,7 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
|
|||||||
* @param {object} params.interactions - ap_interactions collection
|
* @param {object} params.interactions - ap_interactions collection
|
||||||
* @returns {Promise<{ activityId: string }>}
|
* @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 { Announce } = await import("@fedify/fedify/vocab");
|
||||||
const ctx = federation.createContext(
|
const ctx = federation.createContext(
|
||||||
new URL(publicationUrl),
|
new URL(publicationUrl),
|
||||||
@@ -162,7 +170,11 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
|
|||||||
|
|
||||||
// Also send directly to the original post author
|
// Also send directly to the original post author
|
||||||
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
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) {
|
if (recipient) {
|
||||||
try {
|
try {
|
||||||
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
|
await ctx.sendActivity({ identifier: handle }, recipient, announce, {
|
||||||
|
|||||||
@@ -614,6 +614,7 @@ function getFederationOpts(req) {
|
|||||||
handle: pluginOptions.handle || "user",
|
handle: pluginOptions.handle || "user",
|
||||||
publicationUrl: pluginOptions.publicationUrl,
|
publicationUrl: pluginOptions.publicationUrl,
|
||||||
collections: req.app.locals.mastodonCollections,
|
collections: req.app.locals.mastodonCollections,
|
||||||
|
loadRsaKey: pluginOptions.loadRsaKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
*
|
*
|
||||||
* Strategies (tried in order):
|
* Strategies (tried in order):
|
||||||
* 1. lookupObject on post URL → getAttributedTo
|
* 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
|
* 2. Timeline/notification DB lookup → lookupObject on stored author URL
|
||||||
* 3. Extract author URL from post URL pattern → lookupObject
|
* 3. Extract author URL from post URL pattern → lookupObject
|
||||||
*/
|
*/
|
||||||
@@ -60,6 +62,9 @@ export function extractAuthorUrl(postUrl) {
|
|||||||
* @param {object} ctx - Fedify context
|
* @param {object} ctx - Fedify context
|
||||||
* @param {object} documentLoader - Authenticated document loader
|
* @param {object} documentLoader - Authenticated document loader
|
||||||
* @param {object} [collections] - Optional MongoDB collections map (application.collections)
|
* @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<object|null>} - Fedify Actor object or null
|
* @returns {Promise<object|null>} - Fedify Actor object or null
|
||||||
*/
|
*/
|
||||||
export async function resolveAuthor(
|
export async function resolveAuthor(
|
||||||
@@ -67,6 +72,7 @@ export async function resolveAuthor(
|
|||||||
ctx,
|
ctx,
|
||||||
documentLoader,
|
documentLoader,
|
||||||
collections,
|
collections,
|
||||||
|
options = {},
|
||||||
) {
|
) {
|
||||||
// Strategy 1: Look up remote post via Fedify (signed request)
|
// Strategy 1: Look up remote post via Fedify (signed request)
|
||||||
try {
|
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
|
// Strategy 2: Use author URL from timeline or notifications
|
||||||
if (collections) {
|
if (collections) {
|
||||||
const ap_timeline = collections.get("ap_timeline");
|
const ap_timeline = collections.get("ap_timeline");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user