Files
svemagie 56a8b08498 fix: integrate all AP runtime patches into fork source
Integrates 12 runtime patch scripts from indiekit-server directly into
the fork source code, eliminating the need for postinstall patching:

- enrich-actor-data: avatar via getIcon(), handle as @user@domain, banner via getImage()
- conversations-endpoint: real /api/v1/conversations implementation
- stubs-remove-duplicate-routes: dead route removal from stubs.js
- self-follow-guard: prevent self-follow loop
- oauth-token-expiry: clear expiresAt on token exchange
- unify-dm-visibility: unified DM visibility detection
- accounts-id-cache-fallback: check ap_actor_cache before 404
- federation-infra: federation infrastructure fixes
- mastodon-misc: miscellaneous Mastodon API fixes
- mastodon-statuses: status endpoint fixes
- syndication: syndication dedup
- startup-gate-bypass: startup gate bypass

Also strips all // [patch] markers from 16 files (including 4 from prior commit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 16:35:01 +02:00

433 lines
14 KiB
JavaScript

/**
* Compose controllers — reply form via Micropub (public) or native AP (direct).
*/
import { Create, Note, Mention } from "@fedify/fedify/vocab";
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
import { addNotification } from "../storage/notifications.js";
import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
const _mpInternalBase = (() => {
if (process.env.INTERNAL_FETCH_URL) return process.env.INTERNAL_FETCH_URL.replace(/\/+$/, "");
const port = process.env.PORT || "3000";
return `http://localhost:${port}`;
})();
const _mpPublicBase = (
process.env.PUBLICATION_URL || process.env.SITE_URL || ""
).replace(/\/+$/, "");
function _toInternalUrl(url) {
if (!_mpPublicBase || !url.startsWith(_mpPublicBase)) return url;
return _mpInternalBase + url.slice(_mpPublicBase.length);
}
/**
* Fetch syndication targets from the Micropub config endpoint.
* @param {object} application - Indiekit application locals
* @param {string} token - Session access token
* @returns {Promise<Array>}
*/
async function getSyndicationTargets(application, token) {
try {
const micropubEndpoint = application.micropubEndpoint;
if (!micropubEndpoint) return [];
const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http")
? micropubEndpoint
: new URL(micropubEndpoint, application.url).href);
const configUrl = `${micropubUrl}?q=config`;
const configResponse = await fetch(configUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
if (configResponse.ok) {
const config = await configResponse.json();
return config["syndicate-to"] || [];
}
return [];
} catch {
return [];
}
}
/**
* GET /admin/reader/compose — Show compose form.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
export function composeController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const replyTo = request.query.replyTo || "";
// Fetch reply context (the post being replied to)
let replyContext = null;
if (replyTo) {
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
};
// Try to find the post in our timeline first
// Note: Timeline stores uid (canonical AP URL) and url (display URL).
// The card link passes the display URL, so search both fields.
const ap_timeline = collections.ap_timeline;
replyContext = ap_timeline
? await ap_timeline.findOne({ $or: [{ uid: replyTo }, { url: replyTo }] })
: null;
// If not in timeline, try to look up remotely
if (!replyContext && isFederationReady(plugin)) {
try {
const handle = getHandle(plugin);
const ctx = createContext(plugin);
// Use authenticated document loader for Authorized Fetch
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteObject = await lookupWithSecurity(ctx, new URL(replyTo), {
documentLoader,
});
if (remoteObject) {
let authorName = "";
let authorUrl = "";
if (typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo({
documentLoader,
});
const actor = Array.isArray(author) ? author[0] : author;
if (actor) {
authorName =
actor.name?.toString() ||
actor.preferredUsername?.toString() ||
"";
authorUrl = actor.id?.href || "";
}
}
const rawHtml = remoteObject.content?.toString() || "";
replyContext = {
url: replyTo,
name: remoteObject.name?.toString() || "",
content: {
html: sanitizeContent(rawHtml),
text: rawHtml.replace(/<[^>]*>/g, "").slice(0, 300),
},
author: { name: authorName, url: authorUrl },
};
}
} catch (error) {
console.warn(
`[ActivityPub] lookupObject failed for ${replyTo} (compose):`,
error.message,
);
}
}
}
// Check if this is a direct/private message reply by looking at notification metadata
let isDirect = false;
let senderActorUrl = "";
if (replyTo) {
const ap_notifications = application?.collections?.get("ap_notifications");
if (ap_notifications) {
const notif = await ap_notifications.findOne({
$or: [{ uid: replyTo }, { url: replyTo }],
isDirect: true,
});
if (notif) {
isDirect = true;
senderActorUrl = notif.senderActorUrl || notif.actorUrl || "";
}
}
}
// Fetch syndication targets for Micropub path
const token = request.session?.access_token;
const syndicationTargets = token
? await getSyndicationTargets(application, token)
: [];
// Default-check only AP (Fedify) and Bluesky targets
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
for (const target of syndicationTargets) {
const name = target.name || "";
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
}
const csrfToken = getToken(request.session);
response.render("activitypub-compose", {
title: response.locals.__("activitypub.compose.title"),
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
replyTo,
replyContext,
syndicationTargets,
csrfToken,
mountPath,
isDirect,
senderActorUrl,
mediaEndpoint: application.mediaEndpoint || "",
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/compose — Submit reply via Micropub (public) or native AP (direct).
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
export function submitComposeController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).render("error", {
title: "Error",
content: "Invalid CSRF token",
});
}
const { application } = request.app.locals;
const { content, visibility, summary, photo, category } = request.body;
const cwEnabled = request.body["cw-enabled"];
const inReplyTo = request.body["in-reply-to"];
const syndicateTo = request.body["mp-syndicate-to"];
const isDirect = request.body["is-direct"] === "true";
const senderActorUrl = request.body["sender-actor-url"] || "";
if (!content || !content.trim()) {
return response.status(400).render("error", {
title: "Error",
content: response.locals.__("activitypub.compose.errorEmpty"),
});
}
// --- Native AP path for direct/private replies ---
if (isDirect && senderActorUrl && plugin._federation) {
try {
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const actorUri = ctx.getActorUri(handle);
const uuid = crypto.randomUUID();
const noteId = new URL(
`${plugin._publicationUrl.replace(/\/$/, "")}/activitypub/notes/${uuid}`,
);
const note = new Note({
id: noteId,
attributedTo: actorUri,
to: new URL(senderActorUrl),
content: content.trim(),
...(inReplyTo ? { replyTarget: new URL(inReplyTo) } : {}),
tag: new Mention({ href: new URL(senderActorUrl) }),
});
const create = new Create({
id: new URL(`${noteId.href}#create`),
actor: actorUri,
to: new URL(senderActorUrl),
object: note,
});
// Look up the recipient actor directly (senderActorUrl is an actor URL, not a post URL)
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
let recipient;
try {
recipient = await lookupWithSecurity(ctx, new URL(senderActorUrl), { documentLoader });
} catch (lookupError) {
console.warn(`[ActivityPub] Actor lookup failed for ${senderActorUrl}:`, lookupError.message);
}
// Fall back to a minimal Recipient if lookup fails (standard inbox path)
if (!recipient) {
recipient = {
id: new URL(senderActorUrl),
inboxId: new URL(`${senderActorUrl}/inbox`),
};
}
await ctx.sendActivity({ identifier: handle }, recipient, create, {
orderingKey: noteId.href,
});
console.info(`[ActivityPub] Sent direct AP reply to ${senderActorUrl}`);
// Store outbound DM so it appears in the thread view
try {
const ap_notifications = application?.collections?.get("ap_notifications");
if (ap_notifications) {
const hostname = new URL(plugin._publicationUrl).hostname;
await addNotification({ ap_notifications }, {
uid: noteId.href,
url: noteId.href,
type: "mention",
isDirect: true,
direction: "outbound",
senderActorUrl,
actorUrl: actorUri.href,
actorName: plugin.options?.actor?.name || handle,
actorPhoto: plugin.options?.actor?.icon || "",
actorHandle: `@${handle}@${hostname}`,
inReplyTo: inReplyTo || null,
content: { text: content.trim(), html: content.trim() },
published: new Date().toISOString(),
createdAt: new Date().toISOString(),
});
}
} catch (storeError) {
console.warn("[ActivityPub] Failed to store outbound DM:", storeError.message);
}
return response.redirect(`${mountPath}/admin/reader/notifications?tab=mention`);
} catch (error) {
console.error("[ActivityPub] Native AP DM reply failed:", error.message);
return response.status(500).render("error", {
title: "Error",
content: "Failed to send direct reply. Please try again later.",
});
}
}
// --- Public reply path via Micropub ---
const micropubEndpoint = application.micropubEndpoint;
if (!micropubEndpoint) {
return response.status(500).render("error", {
title: "Error",
content: "Micropub endpoint not configured",
});
}
const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http")
? micropubEndpoint
: new URL(micropubEndpoint, application.url).href);
const token = request.session?.access_token;
if (!token) {
return response.redirect(
"/session/login?redirect=" + request.originalUrl,
);
}
const micropubData = new URLSearchParams();
micropubData.append("h", "entry");
micropubData.append("content", content.trim());
if (inReplyTo) {
micropubData.append("in-reply-to", inReplyTo);
}
if (visibility && visibility !== "public") {
micropubData.append("visibility", visibility);
}
if (cwEnabled && summary && summary.trim()) {
micropubData.append("content-warning", summary.trim());
micropubData.append("sensitive", "true");
}
if (syndicateTo) {
const targets = Array.isArray(syndicateTo)
? syndicateTo
: [syndicateTo];
for (const target of targets) {
micropubData.append("mp-syndicate-to", target);
}
}
// Photo (from file-input component — already a URL from media endpoint)
if (photo && photo.trim()) {
micropubData.append("photo", photo.trim());
}
// Tags / categories
if (category) {
const tags = Array.isArray(category)
? category
: category.split(",").map((t) => t.trim()).filter(Boolean);
for (const tag of tags) {
micropubData.append("category[]", tag);
}
}
console.info(
`[ActivityPub] Compose Micropub submission:`,
JSON.stringify({
syndicateTo: syndicateTo || "(none)",
micropubBody: micropubData.toString(),
micropubUrl,
}),
);
const micropubResponse = await fetch(micropubUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: micropubData.toString(),
});
if (
micropubResponse.ok ||
micropubResponse.status === 201 ||
micropubResponse.status === 202
) {
const location = micropubResponse.headers.get("Location");
console.info(
`[ActivityPub] Created blog reply via Micropub: ${location || "success"}`,
);
return response.redirect(`${mountPath}/admin/reader`);
}
const errorBody = await micropubResponse.text();
let errorMessage = `Micropub error: ${micropubResponse.statusText}`;
try {
const errorJson = JSON.parse(errorBody);
if (errorJson.error_description) {
errorMessage = String(errorJson.error_description);
} else if (errorJson.error) {
errorMessage = String(errorJson.error);
}
} catch {
// Not JSON
}
return response.status(micropubResponse.status).render("error", {
title: "Error",
content: errorMessage,
});
} catch (error) {
console.error("[ActivityPub] Compose submit failed:", error.message);
return response.status(500).render("error", {
title: "Error",
content: "Failed to create post. Please try again later.",
});
}
};
}