mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low): Security (Critical): - Escape HTML in OAuth authorization page to prevent XSS (C1) - Add CSRF protection to OAuth authorize flow (C2) - Replace bypassable regex sanitizer with sanitize-html library (C3) - Enforce OAuth scopes on all Mastodon API routes (C4) Security (Medium/Low): - Fix SSRF via DNS resolution before private IP check (M1) - Add rate limiting to API, auth, and app registration endpoints (M2) - Validate redirect_uri on POST /oauth/authorize (M4) - Fix custom emoji URL injection with scheme validation + escaping (M5) - Remove data: scheme from allowed image sources (L6) - Add access token expiry (1hr) and refresh token rotation (90d) (M3) - Hash client secrets before storage (L3) Architecture: - Extract batch-broadcast.js — shared delivery logic (H1a) - Extract init-indexes.js — MongoDB index creation (H1b) - Extract syndicator.js — syndication logic (H1c) - Create federation-actions.js facade for controllers (M6) - index.js reduced from 1810 to ~1169 lines (35%) Performance: - Cache moderation data with 30s TTL + write invalidation (H6) - Increase inbox queue throughput to 10 items/sec (H5) - Make account enrichment non-blocking with fire-and-forget (H4) - Remove ephemeral getReplies/getLikes/getShares from ingest (M11) - Fix LRU caches to use true LRU eviction (L1) - Fix N+1 backfill queries with batch $in lookup (L2) UI/UX: - Split 3441-line reader.css into 15 feature-scoped files (H2) - Extract inline Alpine.js interaction component (H3) - Reduce sidebar navigation from 7 to 3 items (M7) - Add ARIA live regions for dynamic content updates (M8) - Extract shared CW/non-CW content partial (M9) - Document form handling pattern convention (M10) - Add accessible labels to functional emoji icons (L4) - Convert profile editor to Alpine.js (L5) Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md
240 lines
8.2 KiB
JavaScript
240 lines
8.2 KiB
JavaScript
/**
|
|
* ActivityPub syndicator — delivers posts to followers via Fedify.
|
|
* @module syndicator
|
|
*/
|
|
import {
|
|
jf2ToAS2Activity,
|
|
parseMentions,
|
|
} from "./jf2-to-as2.js";
|
|
import { lookupWithSecurity } from "./lookup-helpers.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
|
|
/**
|
|
* Create the ActivityPub syndicator object.
|
|
* @param {object} plugin - ActivityPubEndpoint instance
|
|
* @returns {object} Syndicator compatible with Indiekit's syndicator API
|
|
*/
|
|
export function createSyndicator(plugin) {
|
|
return {
|
|
name: "ActivityPub syndicator",
|
|
options: { checked: plugin.options.checked },
|
|
|
|
get info() {
|
|
const hostname = plugin._publicationUrl
|
|
? new URL(plugin._publicationUrl).hostname
|
|
: "example.com";
|
|
return {
|
|
checked: plugin.options.checked,
|
|
name: `@${plugin.options.actor.handle}@${hostname}`,
|
|
uid: plugin._publicationUrl || "https://example.com/",
|
|
service: {
|
|
name: "ActivityPub (Fediverse)",
|
|
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
|
|
url: plugin._publicationUrl || "https://example.com/",
|
|
},
|
|
};
|
|
},
|
|
|
|
async syndicate(properties) {
|
|
if (!plugin._federation) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const actorUrl = plugin._getActorUrl();
|
|
const handle = plugin.options.actor.handle;
|
|
|
|
const ctx = plugin._federation.createContext(
|
|
new URL(plugin._publicationUrl),
|
|
{ handle, publicationUrl: plugin._publicationUrl },
|
|
);
|
|
|
|
// For replies, resolve the original post author for proper
|
|
// addressing (CC) and direct inbox delivery
|
|
let replyToActor = null;
|
|
if (properties["in-reply-to"]) {
|
|
try {
|
|
const remoteObject = await lookupWithSecurity(ctx,
|
|
new URL(properties["in-reply-to"]),
|
|
);
|
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
|
const author = await remoteObject.getAttributedTo();
|
|
const authorActor = Array.isArray(author) ? author[0] : author;
|
|
if (authorActor?.id) {
|
|
replyToActor = {
|
|
url: authorActor.id.href,
|
|
handle: authorActor.preferredUsername || null,
|
|
recipient: authorActor,
|
|
};
|
|
console.info(
|
|
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Resolve @user@domain mentions in content via WebFinger
|
|
const contentText = properties.content?.html || properties.content || "";
|
|
const mentionHandles = parseMentions(contentText);
|
|
const resolvedMentions = [];
|
|
const mentionRecipients = [];
|
|
|
|
for (const { handle } of mentionHandles) {
|
|
try {
|
|
const mentionedActor = await lookupWithSecurity(ctx,
|
|
new URL(`acct:${handle}`),
|
|
);
|
|
if (mentionedActor?.id) {
|
|
resolvedMentions.push({
|
|
handle,
|
|
actorUrl: mentionedActor.id.href,
|
|
profileUrl: mentionedActor.url?.href || null,
|
|
});
|
|
mentionRecipients.push({
|
|
handle,
|
|
actorUrl: mentionedActor.id.href,
|
|
actor: mentionedActor,
|
|
});
|
|
console.info(
|
|
`[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
|
|
);
|
|
// Still add with no actorUrl so it gets a fallback link
|
|
resolvedMentions.push({ handle, actorUrl: null });
|
|
}
|
|
}
|
|
|
|
const activity = jf2ToAS2Activity(
|
|
properties,
|
|
actorUrl,
|
|
plugin._publicationUrl,
|
|
{
|
|
replyToActorUrl: replyToActor?.url,
|
|
replyToActorHandle: replyToActor?.handle,
|
|
visibility: plugin.options.defaultVisibility,
|
|
mentions: resolvedMentions,
|
|
},
|
|
);
|
|
|
|
if (!activity) {
|
|
await logActivity(plugin._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Syndicate",
|
|
actorUrl: plugin._publicationUrl,
|
|
objectUrl: properties.url,
|
|
summary: `Syndication skipped: could not convert post to AS2`,
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
// Count followers for logging
|
|
const followerCount =
|
|
await plugin._collections.ap_followers.countDocuments();
|
|
|
|
console.info(
|
|
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
|
);
|
|
|
|
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
"followers",
|
|
activity,
|
|
{
|
|
preferSharedInbox: true,
|
|
syncCollection: true,
|
|
orderingKey: properties.url,
|
|
},
|
|
);
|
|
|
|
// For replies, also deliver to the original post author's inbox
|
|
// so their server can thread the reply under the original post
|
|
if (replyToActor?.recipient) {
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
replyToActor.recipient,
|
|
activity,
|
|
{ orderingKey: properties.url },
|
|
);
|
|
console.info(
|
|
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
|
|
);
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
|
|
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
|
|
if (replyToActor?.url === mUrl) continue;
|
|
try {
|
|
await ctx.sendActivity(
|
|
{ identifier: handle },
|
|
mActor,
|
|
activity,
|
|
{ orderingKey: properties.url },
|
|
);
|
|
console.info(
|
|
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
|
|
);
|
|
} catch (error) {
|
|
console.warn(
|
|
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Determine activity type name
|
|
const typeName =
|
|
activity.constructor?.name || "Create";
|
|
const replyNote = replyToActor
|
|
? ` (reply to ${replyToActor.url})`
|
|
: "";
|
|
const mentionNote = mentionRecipients.length > 0
|
|
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
|
|
: "";
|
|
|
|
await logActivity(plugin._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: typeName,
|
|
actorUrl: plugin._publicationUrl,
|
|
objectUrl: properties.url,
|
|
targetUrl: properties["in-reply-to"] || undefined,
|
|
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
|
|
});
|
|
|
|
console.info(
|
|
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
|
|
);
|
|
|
|
return properties.url || undefined;
|
|
} catch (error) {
|
|
console.error("[ActivityPub] Syndication failed:", error.message);
|
|
await logActivity(plugin._collections.ap_activities, {
|
|
direction: "outbound",
|
|
type: "Syndicate",
|
|
actorUrl: plugin._publicationUrl,
|
|
objectUrl: properties.url,
|
|
summary: `Syndication failed: ${error.message}`,
|
|
}).catch(() => {});
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
delete: async (url) => plugin.delete(url),
|
|
update: async (properties) => plugin.update(properties),
|
|
};
|
|
}
|