/** * 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} */ 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.", }); } }; }