/**
* Status endpoints for Mastodon Client API.
*
* GET /api/v1/statuses/:id — single status
* GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
* POST /api/v1/statuses — create post via Micropub pipeline
* PUT /api/v1/statuses/:id — edit an existing post
* DELETE /api/v1/statuses/:id — delete post via Micropub pipeline
* GET /api/v1/statuses/:id/history — edit history
* POST /api/v1/statuses/:id/favourite — like a post
* POST /api/v1/statuses/:id/unfavourite — unlike a post
* POST /api/v1/statuses/:id/reblog — boost a post
* POST /api/v1/statuses/:id/unreblog — unboost a post
* POST /api/v1/statuses/:id/bookmark — bookmark a post
* POST /api/v1/statuses/:id/unbookmark — remove bookmark
* PUT /api/v1/statuses/:id — edit post content via Micropub pipeline
* POST /api/v1/statuses/:id/pin — pin post to profile
* POST /api/v1/statuses/:id/unpin — unpin post from profile
*/
import crypto from "node:crypto";
import express from "express";
import { Note, Create, Mention } from "@fedify/fedify/vocab";
import { ObjectId } from "mongodb";
import { serializeStatus } from "../entities/status.js";
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
import {
likePost, unlikePost,
boostPost, unboostPost,
bookmarkPost, unbookmarkPost,
} from "../helpers/interactions.js";
import { addTimelineItem } from "../../storage/timeline.js";
import { lookupWithSecurity } from "../../lookup-helpers.js";
import { addNotification } from "../../storage/notifications.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Load interaction state if authenticated
const interactionState = await loadItemInteractions(collections, item);
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [item]);
const status = serializeStatus(item, {
baseUrl,
...interactionState,
pinnedIds: new Set(),
replyIdMap,
replyAccountIdMap,
});
res.json(status);
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const item = await findTimelineItemById(collections.ap_timeline, id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Find ancestors: walk up the inReplyTo chain
const ancestors = [];
let currentReplyTo = item.inReplyTo;
const visited = new Set();
while (currentReplyTo && ancestors.length < 40) {
if (visited.has(currentReplyTo)) break;
visited.add(currentReplyTo);
const parent = await collections.ap_timeline.findOne({
$or: [{ uid: currentReplyTo }, { url: currentReplyTo }],
});
if (!parent) break;
ancestors.unshift(parent);
currentReplyTo = parent.inReplyTo;
}
// Find descendants: items that reply to this post's uid or url
const targetUrls = [item.uid, item.url].filter(Boolean);
let descendants = [];
if (targetUrls.length > 0) {
// Get direct replies first
const directReplies = await collections.ap_timeline
.find({ inReplyTo: { $in: targetUrls } })
.sort({ _id: 1 })
.limit(60)
.toArray();
descendants = directReplies;
// Also fetch replies to direct replies (2 levels deep)
if (directReplies.length > 0) {
const replyUrls = directReplies
.flatMap((r) => [r.uid, r.url].filter(Boolean));
const nestedReplies = await collections.ap_timeline
.find({ inReplyTo: { $in: replyUrls } })
.sort({ _id: 1 })
.limit(60)
.toArray();
descendants.push(...nestedReplies);
}
}
// Serialize all items
const allItems = [...ancestors, ...descendants];
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
// Load real interaction state for thread context
const ctxFavouritedIds = new Set();
const ctxRebloggedIds = new Set();
const ctxBookmarkedIds = new Set();
if (allItems.length > 0 && collections.ap_interactions) {
const ctxUrlToUid = new Map();
for (const ci of allItems) {
if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); }
if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); }
}
const ctxLookupUrls = [...ctxUrlToUid.keys()];
if (ctxLookupUrls.length > 0) {
const ctxInteractions = await collections.ap_interactions
.find({ objectUrl: { $in: ctxLookupUrls } })
.toArray();
for (const ci of ctxInteractions) {
const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl;
if (ci.type === "like") ctxFavouritedIds.add(uid);
else if (ci.type === "boost") ctxRebloggedIds.add(uid);
else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid);
}
}
}
const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap };
res.json({
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
descendants: descendants.map((d) => serializeStatus(d, serializeOpts)),
});
} catch (error) {
next(error);
}
});
// ─── POST /api/v1/statuses ───────────────────────────────────────────────────
// Creates a post via the Micropub pipeline so it goes through the full flow:
// Micropub → content file → Eleventy build → syndication → AP federation.
router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
const { application, publication } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
// Idempotency-Key support — prevent duplicate posts on client retry
const idempotencyKey = req.headers["idempotency-key"];
if (idempotencyKey && collections.ap_idempotency) {
const { createHash } = await import("node:crypto");
const key = createHash("sha256")
.update(`${baseUrl}:${idempotencyKey}`)
.digest("hex");
const cached = await collections.ap_idempotency.findOne({ key });
if (cached) {
return res.json(cached.response);
}
}
const {
status: statusText,
spoiler_text: spoilerText,
visibility = "public",
sensitive = false,
language,
in_reply_to_id: inReplyToId,
media_ids: mediaIds,
} = req.body;
if (!statusText && (!mediaIds || mediaIds.length === 0)) {
return res.status(422).json({ error: "Validation failed: Text content is required" });
}
// Resolve in_reply_to URL from status ID (cursor or ObjectId)
let inReplyTo = null;
if (inReplyToId) {
const replyItem = await findTimelineItemById(collections.ap_timeline, inReplyToId);
if (replyItem) {
inReplyTo = replyItem.uid || replyItem.url;
}
}
// Resolve media_ids to URLs from ap_media collection
const mediaUrls = [];
if (mediaIds && mediaIds.length > 0 && collections.ap_media) {
const { ObjectId: MediaObjectId } = await import("mongodb");
for (const mediaId of Array.isArray(mediaIds) ? mediaIds : [mediaIds]) {
try {
const media = await collections.ap_media.findOne({
_id: new MediaObjectId(mediaId),
});
if (media) {
mediaUrls.push({
url: media.url,
alt: media.description || "",
type: media.mimeType,
});
}
} catch {
/* invalid ObjectId, skip */
}
}
}
// Build JF2 properties for the Micropub pipeline.
// Provide both text and html — linkify URLs since Micropub's markdown-it
// doesn't have linkify enabled. Mentions are preserved as plain text;
// the AP syndicator resolves them via WebFinger for federation delivery.
const contentText = statusText || "";
const contentHtml = contentText
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/(https?:\/\/[^\s<>&"')\]]+)/g, '$1')
.replace(/\n/g, "
");
const jf2 = {
type: "entry",
content: { text: contentText, html: `
${contentHtml}
` }, }; if (inReplyTo) { jf2["in-reply-to"] = inReplyTo; } if (visibility && visibility !== "public" && visibility !== "direct") { jf2.visibility = visibility; } // Use content-warning (not summary) to match native reader behavior if (spoilerText) { jf2["content-warning"] = spoilerText; jf2.sensitive = "true"; } if (language) { jf2["mp-language"] = language; } // ── Direct messages: bypass Micropub, send via native AP DM path ────────── // Mastodon clients send visibility="direct" for DMs. These must NOT create // a public blog post — instead send a Create/Note activity directly to the // mentioned recipient, same as the web compose form does. if (visibility === "direct") { const federation = pluginOptions.federation; const handle = pluginOptions.handle || "user"; const publicationUrl = pluginOptions.publicationUrl || baseUrl; if (!federation) { return res.status(503).json({ error: "Federation not available" }); } // Extract first @user@domain mention from status text const mentionMatch = (statusText || "").match(/@([\w.-]+@[\w.-]+)/); if (!mentionMatch) { return res.status(422).json({ error: "Direct messages must mention a recipient (@user@domain)" }); } const mentionHandle = mentionMatch[1]; const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl, }); const actorUri = ctx.getActorUri(handle); const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); // Resolve @user@domain → actor URL via WebFinger let recipientActorUrl; try { const webfingerUrl = `https://${mentionHandle.split("@")[1]}/.well-known/webfinger?resource=acct:${mentionHandle}`; const wfRes = await fetch(webfingerUrl, { headers: { Accept: "application/jrd+json" } }); if (wfRes.ok) { const wf = await wfRes.json(); recipientActorUrl = wf.links?.find((l) => l.rel === "self" && l.type?.includes("activity"))?.href; } } catch { /* fall through to lookup */ } // Fallback: resolve via federation lookup if (!recipientActorUrl) { try { const actor = await lookupWithSecurity(ctx, `acct:${mentionHandle}`, { documentLoader }); if (actor?.id) recipientActorUrl = actor.id.href; } catch { /* ignore */ } } if (!recipientActorUrl) { return res.status(422).json({ error: `Could not resolve recipient: @${mentionHandle}` }); } const uuid = crypto.randomUUID(); const noteId = new URL(`${publicationUrl.replace(/\/$/, "")}/activitypub/notes/${uuid}`); const note = new Note({ id: noteId, attributedTo: actorUri, to: new URL(recipientActorUrl), content: (statusText || "").trim(), ...(inReplyTo ? { replyTarget: new URL(inReplyTo) } : {}), tag: new Mention({ href: new URL(recipientActorUrl) }), }); const create = new Create({ id: new URL(`${noteId.href}#create`), actor: actorUri, to: new URL(recipientActorUrl), object: note, }); let recipient; try { recipient = await lookupWithSecurity(ctx, new URL(recipientActorUrl), { documentLoader }); } catch { /* ignore */ } if (!recipient) { recipient = { id: new URL(recipientActorUrl), inboxId: new URL(`${recipientActorUrl}/inbox`), }; } await ctx.sendActivity({ identifier: handle }, recipient, create, { orderingKey: noteId.href, }); console.info(`[Mastodon API] Sent DM to ${recipientActorUrl}`); const now = new Date().toISOString(); const hostname = new URL(publicationUrl).hostname; const profile = await collections.ap_profile.findOne({}); // Store in ap_notifications for the DM thread view try { const ap_notifications = collections.ap_notifications; if (ap_notifications) { await addNotification({ ap_notifications }, { uid: noteId.href, url: noteId.href, type: "mention", isDirect: true, direction: "outbound", senderActorUrl: recipientActorUrl, actorUrl: actorUri.href, actorName: profile?.name || handle, actorPhoto: profile?.icon || "", actorHandle: `@${handle}@${hostname}`, inReplyTo: inReplyTo || null, content: { text: (statusText || "").trim(), html: (statusText || "").trim() }, published: now, createdAt: now, }); } } catch (storeError) { console.warn("[Mastodon API] Failed to store outbound DM in notifications:", storeError.message); } // Store in ap_timeline with visibility=direct so serializeStatus can // produce a full Mastodon status object. Home/public timelines already // exclude direct-visibility items (visibility: { $nin: ["direct"] }). const timelineItem = { uid: noteId.href, url: noteId.href, type: "note", visibility: "direct", content: { text: (statusText || "").trim(), html: (statusText || "").trim(), }, author: { name: profile?.name || handle, url: actorUri.href, photo: profile?.icon || "", handle: `@${handle}@${hostname}`, }, published: now, createdAt: now, inReplyTo: inReplyTo || null, category: [], counts: { likes: 0, boosts: 0, replies: 0 }, }; try { await addTimelineItem(collections, timelineItem); } catch (storeError) { console.warn("[Mastodon API] Failed to store outbound DM in timeline:", storeError.message); } // Return a full serialized status so clients (Phanpy, Elk) can render it const status = serializeStatus(timelineItem, { baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(), bookmarkedIds: new Set(), pinnedIds: new Set(), }); return res.json(status); } // ── End DM path ─────────────────────────────────────────────────────────── // Syndicate to AP only — posts from Mastodon clients belong to the fediverse. // Never cross-post to Bluesky (conversations stay in their protocol). // The publication URL is the AP syndicator's uid. const publicationUrl = pluginOptions.publicationUrl || baseUrl; jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"]; // Add media from media_ids for (const m of mediaUrls) { if (m.type?.startsWith("image/")) { if (!jf2.photo) jf2.photo = []; jf2.photo.push({ url: m.url, alt: m.alt }); } else if (m.type?.startsWith("video/")) { if (!jf2.video) jf2.video = []; jf2.video.push(m.url); } else if (m.type?.startsWith("audio/")) { if (!jf2.audio) jf2.audio = []; jf2.audio.push(m.url); } } // Create post via Micropub pipeline (same internal functions) const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js"); const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js"); const data = await postData.create(application, publication, jf2); await postContent.create(publication, data); const postUrl = data.properties.url; console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`); // Return a minimal status to the Mastodon client. // Eagerly insert own post into ap_timeline so the Mastodon client can resolve // in_reply_to_id for this post immediately, without waiting for the build webhook. // The AP syndicator will upsert the same uid later via $setOnInsert (no-op). const profile = await collections.ap_profile.findOne({}); const handle = pluginOptions.handle || "user"; let _tlItem = null; try { const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); _tlItem = await addTimelineItem(collections, { uid: postUrl, url: postUrl, type: data.properties["post-type"] || "note", content: { text: contentText, html: `${contentHtml}
` }, author: { name: profile?.name || handle, url: profile?.url || publicationUrl, photo: profile?.icon || "", handle: `@${handle}@${_ph}`, emojis: [], bot: false, }, published: data.properties.published || new Date().toISOString(), createdAt: new Date().toISOString(), inReplyTo: inReplyTo || null, inReplyToId: inReplyToId || null, visibility: jf2.visibility || "public", sensitive: jf2.sensitive === "true", category: [], counts: { likes: 0, boosts: 0, replies: 0 }, }); } catch (tlErr) { console.warn(`[Mastodon API] Failed to pre-insert own post into timeline: ${tlErr.message}`); } const statusResponse = { id: _tlItem?._id?.toString() || String(Date.now()), created_at: new Date().toISOString(), content: `${contentHtml}
`, url: postUrl, uri: postUrl, visibility: visibility || "public", sensitive: sensitive === true || sensitive === "true", spoiler_text: spoilerText || "", in_reply_to_id: inReplyToId || null, in_reply_to_account_id: null, language: language || null, replies_count: 0, reblogs_count: 0, favourites_count: 0, favourited: false, reblogged: false, bookmarked: false, account: { id: "owner", username: handle, acct: handle, display_name: profile?.name || handle, url: profile?.url || publicationUrl, avatar: profile?.icon || "", avatar_static: profile?.icon || "", header: "", header_static: "", followers_count: 0, following_count: 0, statuses_count: 0, emojis: [], fields: [], }, media_attachments: [], mentions: extractMentions(contentText).map(m => ({ id: "0", username: m.name.split("@")[1] || m.name, acct: m.name.replace(/^@/, ""), url: m.url, })), tags: [], emojis: [], }; // Cache response for idempotency if (idempotencyKey && collections.ap_idempotency) { const { createHash } = await import("node:crypto"); const key = createHash("sha256") .update(`${baseUrl}:${idempotencyKey}`) .digest("hex"); await collections.ap_idempotency .insertOne({ key, response: statusResponse, createdAt: new Date() }) .catch(() => {}); } res.json(statusResponse); } catch (error) { next(error); } }); // ─── DELETE /api/v1/statuses/:id ──────────────────────────────────────────── // Deletes via Micropub pipeline (removes content file + MongoDB post) and // cleans up the ap_timeline entry. router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => { try { const { application, publication } = req.app.locals; const { id } = req.params; const collections = req.app.locals.mastodonCollections; const baseUrl = `${req.protocol}://${req.get("host")}`; const item = await findTimelineItemById(collections.ap_timeline, id); if (!item) { return res.status(404).json({ error: "Record not found" }); } // Verify ownership — only allow deleting own posts const profile = await collections.ap_profile.findOne({}); if (profile && item.author?.url !== profile.url) { return res.status(403).json({ error: "This action is not allowed" }); } // Serialize before deleting (Mastodon returns the deleted status with text source) const serialized = serializeStatus(item, { baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(), bookmarkedIds: new Set(), pinnedIds: new Set(), }); serialized.text = item.content?.text || ""; // Delete via Micropub pipeline (removes content file from store + MongoDB posts) const postUrl = item.uid || item.url; try { const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js"); const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js"); const existingPost = await postData.read(application, postUrl); if (existingPost) { const deletedData = await postData.delete(application, postUrl); await postContent.delete(publication, deletedData); console.info(`[Mastodon API] Deleted post via Micropub: ${postUrl}`); } } catch (err) { // Log but don't block — the post may not exist in Micropub (e.g. old pre-pipeline posts) console.warn(`[Mastodon API] Micropub delete failed for ${postUrl}: ${err.message}`); } // Delete from timeline await collections.ap_timeline.deleteOne({ _id: item._id }); // Broadcast AP Delete activity to followers const _pluginOpts = req.app.locals.mastodonPluginOptions || {}; if (_pluginOpts.broadcastDelete && postUrl) { _pluginOpts.broadcastDelete(postUrl).catch((err) => console.warn(`[Mastodon API] broadcastDelete failed for ${postUrl}: ${err.message}`), ); } // Clean up interactions if (collections.ap_interactions && item.uid) { await collections.ap_interactions.deleteMany({ objectUrl: item.uid }); } res.json(serialized); } catch (error) { next(error); } }); // ─── PUT /api/v1/statuses/:id ─────────────────────────────────────────────── // Edit an existing status. Stores the previous version for history. router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => { try { const { application, publication } = req.app.locals; const collections = req.app.locals.mastodonCollections; const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const baseUrl = `${req.protocol}://${req.get("host")}`; const localPublicationUrl = publication?.me || pluginOptions.publicationUrl || application?.url; const item = await findTimelineItemById(collections.ap_timeline, req.params.id); if (!item) { return res.status(404).json({ error: "Record not found" }); } // Verify ownership — only the local author can edit if (!item.author?.url || item.author.url !== localPublicationUrl) { return res.status(403).json({ error: "This action is not allowed" }); } const { status: statusText, spoiler_text: spoilerText, sensitive, language, } = req.body; // Store current version in edit history if (collections.ap_status_edits) { await collections.ap_status_edits.insertOne({ statusId: req.params.id, content: item.content || {}, summary: item.summary || "", sensitive: item.sensitive || false, media: [ ...(item.photo || []), ...(item.video || []), ...(item.audio || []), ], editedAt: new Date().toISOString(), }); } // Send update via Micropub const postUrl = item.uid || item.url; if (postUrl && application.micropubEndpoint) { const micropubUrl = application.micropubEndpoint.startsWith("http") ? application.micropubEndpoint : new URL(application.micropubEndpoint, application.url).href; const token = req.session?.access_token || req.mastodonToken?.indieauthToken || req.mastodonToken?.accessToken; if (token) { const updatePayload = { action: "update", url: postUrl, replace: {}, }; if (statusText !== undefined) { updatePayload.replace.content = [statusText]; } if (spoilerText !== undefined) { updatePayload.replace["content-warning"] = spoilerText ? [spoilerText] : []; updatePayload.replace.sensitive = [spoilerText ? "true" : "false"]; } if (sensitive !== undefined && spoilerText === undefined) { updatePayload.replace.sensitive = [sensitive === true || sensitive === "true" ? "true" : "false"]; } try { await fetch(micropubUrl, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(updatePayload), }); } catch (err) { console.warn( `[Mastodon API] Micropub update failed: ${err.message}`, ); } } } // Update timeline item directly const updateFields = {}; if (statusText !== undefined) { const contentHtml = statusText .replace(/&/g, "&") .replace(//g, ">") .replace( /(https?:\/\/[^\s<>&"')\]]+)/g, '$1', ) .replace(/\n/g, "