56a8b08498
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>
433 lines
14 KiB
JavaScript
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.",
|
|
});
|
|
}
|
|
};
|
|
}
|