mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
- FEP-8fcf: add syncCollection to Undo(Announce) sendActivity - FEP-fe34: centralized lookupWithSecurity() helper with crossOrigin: "ignore" on all 23 lookupObject call sites - Custom emoji: replaceCustomEmoji() renders :shortcode: as inline <img> in content and actor display names - Manual follow approval: profile toggle, ap_pending_follows collection, approve/reject controllers with federation, pending tab on followers page, follow_request notification type - Coverage audit updated to v2.12.x (overall ~70% → ~82%) Confab-Link: http://localhost:8080/sessions/1f1e729b-0087-499e-a991-f36f46211fe4
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
/**
|
|
* Messages controllers — DM inbox, compose, and send.
|
|
* Direct messages bypass Micropub and use Fedify ctx.sendActivity() directly.
|
|
*/
|
|
|
|
import { getToken, validateToken } from "../csrf.js";
|
|
import { sanitizeContent } from "../timeline-store.js";
|
|
import { lookupWithSecurity } from "../lookup-helpers.js";
|
|
import {
|
|
getMessages,
|
|
getConversationPartners,
|
|
getUnreadMessageCount,
|
|
markMessagesRead,
|
|
markAllMessagesRead,
|
|
clearAllMessages,
|
|
deleteMessage,
|
|
addMessage,
|
|
} from "../storage/messages.js";
|
|
|
|
/**
|
|
* GET /admin/reader/messages — Messages inbox with conversation sidebar.
|
|
* @param {string} mountPath - Plugin mount path
|
|
*/
|
|
export function messagesController(mountPath) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
const { application } = request.app.locals;
|
|
const collections = {
|
|
ap_messages: application?.collections?.get("ap_messages"),
|
|
};
|
|
|
|
const partner = request.query.partner || null;
|
|
const before = request.query.before;
|
|
const limit = Number.parseInt(request.query.limit || "20", 10);
|
|
|
|
const options = { before, limit };
|
|
if (partner) {
|
|
options.partner = partner;
|
|
}
|
|
|
|
// Get messages + conversation partners + unread count + our profile in parallel
|
|
const profileCol = application?.collections?.get("ap_profile");
|
|
const [result, partners, unreadCount, myProfile] = await Promise.all([
|
|
getMessages(collections, options),
|
|
getConversationPartners(collections),
|
|
getUnreadMessageCount(collections),
|
|
profileCol ? profileCol.findOne({}) : null,
|
|
]);
|
|
|
|
// Auto mark-read when viewing a specific conversation
|
|
if (partner) {
|
|
await markMessagesRead(collections, partner);
|
|
}
|
|
|
|
const csrfToken = getToken(request.session);
|
|
|
|
response.render("activitypub-messages", {
|
|
title: response.locals.__("activitypub.messages.title"),
|
|
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
|
|
items: result.items,
|
|
before: result.before,
|
|
partners,
|
|
activePartner: partner,
|
|
unreadCount,
|
|
myProfile,
|
|
csrfToken,
|
|
mountPath,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* GET /admin/reader/messages/compose — DM compose form.
|
|
* @param {string} mountPath - Plugin mount path
|
|
* @param {object} plugin - ActivityPub plugin instance
|
|
*/
|
|
export function messageComposeController(mountPath, plugin) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
const to = request.query.to || "";
|
|
const replyTo = request.query.replyTo || "";
|
|
|
|
// Load reply context if replying to a specific message
|
|
let replyContext = null;
|
|
if (replyTo) {
|
|
const { application } = request.app.locals;
|
|
const messagesCol = application?.collections?.get("ap_messages");
|
|
if (messagesCol) {
|
|
replyContext = await messagesCol.findOne({ uid: replyTo });
|
|
}
|
|
}
|
|
|
|
const csrfToken = getToken(request.session);
|
|
|
|
response.render("activitypub-message-compose", {
|
|
title: response.locals.__("activitypub.messages.compose"),
|
|
readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
|
|
to,
|
|
replyTo,
|
|
replyContext,
|
|
csrfToken,
|
|
mountPath,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /admin/reader/messages/compose — Send a DM via Fedify.
|
|
* Bypasses Micropub — sends Create(Note) directly with DM addressing.
|
|
* @param {string} mountPath - Plugin mount path
|
|
* @param {object} plugin - ActivityPub plugin instance
|
|
*/
|
|
export function submitMessageController(mountPath, plugin) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
if (!validateToken(request)) {
|
|
return response.status(403).redirect(`${mountPath}/admin/reader/messages/compose`);
|
|
}
|
|
|
|
const { to, content, replyTo } = request.body;
|
|
|
|
if (!to || !to.trim()) {
|
|
return response.status(400).render("activitypub-message-compose", {
|
|
title: response.locals.__("activitypub.messages.compose"),
|
|
readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
|
|
to: "",
|
|
replyTo: replyTo || "",
|
|
replyContext: null,
|
|
csrfToken: getToken(request.session),
|
|
mountPath,
|
|
error: response.locals.__("activitypub.messages.errorNoRecipient"),
|
|
});
|
|
}
|
|
|
|
if (!content || !content.trim()) {
|
|
return response.status(400).render("activitypub-message-compose", {
|
|
title: response.locals.__("activitypub.messages.compose"),
|
|
readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
|
|
to,
|
|
replyTo: replyTo || "",
|
|
replyContext: null,
|
|
csrfToken: getToken(request.session),
|
|
mountPath,
|
|
error: response.locals.__("activitypub.messages.errorEmpty"),
|
|
});
|
|
}
|
|
|
|
if (!plugin._federation) {
|
|
return response.status(503).render("activitypub-message-compose", {
|
|
title: response.locals.__("activitypub.messages.compose"),
|
|
readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
|
|
to,
|
|
replyTo: replyTo || "",
|
|
replyContext: null,
|
|
csrfToken: getToken(request.session),
|
|
mountPath,
|
|
error: "Federation not initialized",
|
|
});
|
|
}
|
|
|
|
const { Create, Note, Mention } = await import("@fedify/fedify/vocab");
|
|
const { Temporal } = await import("@js-temporal/polyfill");
|
|
const handle = plugin.options.actor.handle;
|
|
const ctx = plugin._federation.createContext(
|
|
new URL(plugin._publicationUrl),
|
|
{ handle, publicationUrl: plugin._publicationUrl },
|
|
);
|
|
|
|
const documentLoader = await ctx.getDocumentLoader({
|
|
identifier: handle,
|
|
});
|
|
|
|
// Resolve recipient — accept @user@domain or full URL
|
|
let recipient;
|
|
try {
|
|
const recipientInput = to.trim();
|
|
if (recipientInput.startsWith("http")) {
|
|
recipient = await lookupWithSecurity(ctx,recipientInput, { documentLoader });
|
|
} else {
|
|
// Handle @user@domain format
|
|
const handle = recipientInput.replace(/^@/, "");
|
|
recipient = await lookupWithSecurity(ctx,handle, { documentLoader });
|
|
}
|
|
} catch {
|
|
recipient = null;
|
|
}
|
|
|
|
if (!recipient?.id) {
|
|
return response.status(404).render("activitypub-message-compose", {
|
|
title: response.locals.__("activitypub.messages.compose"),
|
|
readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
|
|
to,
|
|
replyTo: replyTo || "",
|
|
replyContext: null,
|
|
csrfToken: getToken(request.session),
|
|
mountPath,
|
|
error: response.locals.__("activitypub.messages.errorRecipientNotFound"),
|
|
});
|
|
}
|
|
|
|
// Build Create(Note) with DM addressing — to: recipient only, no PUBLIC_COLLECTION
|
|
const uuid = crypto.randomUUID();
|
|
const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
|
|
const noteId = `${baseUrl}/activitypub/messages/${uuid}`;
|
|
const now = Temporal.Now.instant();
|
|
|
|
// Sanitize outbound content — basic paragraph wrapping
|
|
const htmlContent = `<p>${sanitizeContent(content.trim())}</p>`;
|
|
|
|
const note = new Note({
|
|
id: new URL(noteId),
|
|
attributedTo: ctx.getActorUri(handle),
|
|
tos: [recipient.id],
|
|
tags: [
|
|
new Mention({
|
|
href: recipient.id,
|
|
name: recipient.preferredUsername
|
|
? `@${recipient.preferredUsername}`
|
|
: recipient.id.href,
|
|
}),
|
|
],
|
|
content: htmlContent,
|
|
published: now,
|
|
replyTarget: replyTo ? new URL(replyTo) : null,
|
|
});
|
|
|
|
const create = new Create({
|
|
id: new URL(`${noteId}#activity`),
|
|
actor: ctx.getActorUri(handle),
|
|
tos: [recipient.id],
|
|
object: note,
|
|
published: now,
|
|
});
|
|
|
|
await ctx.sendActivity({ identifier: handle }, recipient, create, {
|
|
orderingKey: recipient.id.href,
|
|
});
|
|
|
|
// Store outbound message locally
|
|
const { application } = request.app.locals;
|
|
const collections = {
|
|
ap_messages: application?.collections?.get("ap_messages"),
|
|
};
|
|
|
|
const recipientName = recipient.name?.toString() ||
|
|
recipient.preferredUsername?.toString() ||
|
|
recipient.id.href;
|
|
const recipientHandle = recipient.preferredUsername
|
|
? `@${recipient.preferredUsername}@${recipient.id.hostname}`
|
|
: recipient.id.href;
|
|
|
|
// Get our actor's icon for the outbound message
|
|
const profileCol = application?.collections?.get("ap_profile");
|
|
const profile = profileCol ? await profileCol.findOne({}) : null;
|
|
|
|
await addMessage(collections, {
|
|
uid: noteId,
|
|
actorUrl: recipient.id.href,
|
|
actorName: recipientName,
|
|
actorPhoto: recipient.iconUrl?.href || recipient.icon?.url?.href || "",
|
|
actorHandle: recipientHandle,
|
|
content: {
|
|
text: content.trim(),
|
|
html: htmlContent,
|
|
},
|
|
inReplyTo: replyTo || null,
|
|
conversationId: recipient.id.href,
|
|
direction: "outbound",
|
|
published: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
|
|
console.info(`[ActivityPub] Sent DM to ${recipientName} (${recipient.id.href})`);
|
|
|
|
return response.redirect(`${mountPath}/admin/reader/messages?partner=${encodeURIComponent(recipient.id.href)}`);
|
|
} catch (error) {
|
|
console.error("[ActivityPub] DM send failed:", error.message);
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /admin/reader/messages/mark-read — Mark all messages as read.
|
|
*/
|
|
export function markAllMessagesReadController(mountPath) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
if (!validateToken(request)) {
|
|
return response.status(403).redirect(`${mountPath}/admin/reader/messages`);
|
|
}
|
|
|
|
const { application } = request.app.locals;
|
|
const collections = {
|
|
ap_messages: application?.collections?.get("ap_messages"),
|
|
};
|
|
|
|
await markAllMessagesRead(collections);
|
|
|
|
return response.redirect(`${mountPath}/admin/reader/messages`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /admin/reader/messages/clear — Delete all messages.
|
|
*/
|
|
export function clearAllMessagesController(mountPath) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
if (!validateToken(request)) {
|
|
return response.status(403).redirect(`${mountPath}/admin/reader/messages`);
|
|
}
|
|
|
|
const { application } = request.app.locals;
|
|
const collections = {
|
|
ap_messages: application?.collections?.get("ap_messages"),
|
|
};
|
|
|
|
await clearAllMessages(collections);
|
|
|
|
return response.redirect(`${mountPath}/admin/reader/messages`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /admin/reader/messages/delete — Delete a single message.
|
|
*/
|
|
export function deleteMessageController(mountPath) {
|
|
return async (request, response, next) => {
|
|
try {
|
|
if (!validateToken(request)) {
|
|
return response.status(403).json({
|
|
success: false,
|
|
error: "Invalid CSRF token",
|
|
});
|
|
}
|
|
|
|
const { uid } = request.body;
|
|
|
|
if (!uid) {
|
|
return response.status(400).json({
|
|
success: false,
|
|
error: "Missing message UID",
|
|
});
|
|
}
|
|
|
|
const { application } = request.app.locals;
|
|
const collections = {
|
|
ap_messages: application?.collections?.get("ap_messages"),
|
|
};
|
|
|
|
await deleteMessage(collections, uid);
|
|
|
|
// Support both JSON (fetch) and form redirect
|
|
if (request.headers.accept?.includes("application/json")) {
|
|
return response.json({ success: true, uid });
|
|
}
|
|
|
|
return response.redirect(`${mountPath}/admin/reader/messages`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
}
|