feat(mastodon-api): implement PUT /api/v1/statuses/:id (edit post)

Adds the Mastodon Client API edit endpoint, which was returning 501 so
"Beitrag bearbeiten" / edit post always failed.

Flow:
1. Look up timeline item by cursor ID; 403 if not own post
2. Build Micropub replace operation for content/summary/sensitive/language
   and call postData.update() + postContent.update() to update MongoDB
   posts collection and the content file on disk
3. Patch ap_timeline with new content, summary, sensitive, and updatedAt
   (serializeStatus reads updatedAt → edited_at field)
4. Broadcast AP Update(Note) to all followers via shared inbox so remote
   servers can display the edit indicator
5. Return serialized status with edited_at set

Also adds Update to the top-level @fedify/fedify/vocab import and updates
the module-level comment block to list the new route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-23 11:32:48 +01:00
parent b5ebf6a1e4
commit e319c348d0

View File

@@ -11,10 +11,13 @@
* 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 { Note, Create, Mention, Update } from "@fedify/fedify/vocab";
import { ObjectId } from "mongodb";
import { serializeStatus } from "../entities/status.js";
import { decodeCursor } from "../helpers/pagination.js";
@@ -523,6 +526,128 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
}
});
// ─── PUT /api/v1/statuses/:id ───────────────────────────────────────────────
// Edit a post: update content via Micropub pipeline, patch ap_timeline,
// and broadcast an AP Update(Note) to followers.
router.put("/api/v1/statuses/:id", async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
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 {
status: statusText,
spoiler_text: spoilerText,
sensitive,
language,
} = req.body;
if (statusText === undefined) {
return res.status(422).json({ error: "Validation failed: Text content is required" });
}
const item = await findTimelineItemById(collections.ap_timeline, req.params.id);
if (!item) {
return res.status(404).json({ error: "Record not found" });
}
// Verify ownership — only allow editing 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" });
}
const postUrl = item.uid || item.url;
const now = new Date().toISOString();
// Update via Micropub pipeline (updates MongoDB posts + content file)
let updatedContent = processStatusContent({ text: statusText, html: "" }, statusText);
try {
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
const operation = { replace: { content: [statusText] } };
if (spoilerText !== undefined) operation.replace.summary = [spoilerText];
if (sensitive !== undefined) operation.replace.sensitive = [String(sensitive)];
if (language !== undefined) operation.replace["mp-language"] = [language];
const updatedPost = await postData.update(application, publication, postUrl, operation);
if (updatedPost) {
await postContent.update(publication, updatedPost, postUrl);
const rawContent = updatedPost.properties?.content;
if (rawContent) {
updatedContent = processStatusContent(
typeof rawContent === "string" ? { text: rawContent, html: "" } : rawContent,
statusText,
);
}
}
} catch (err) {
console.warn(`[Mastodon API] Micropub update failed for ${postUrl}: ${err.message}`);
}
// Patch the ap_timeline document
const newSummary = spoilerText !== undefined ? spoilerText : (item.summary || "");
const newSensitive = sensitive !== undefined
? (sensitive === true || sensitive === "true")
: (item.sensitive || false);
await collections.ap_timeline.updateOne(
{ _id: item._id },
{ $set: { content: updatedContent, summary: newSummary, sensitive: newSensitive, updatedAt: now } },
);
const updatedItem = { ...item, content: updatedContent, summary: newSummary, sensitive: newSensitive, updatedAt: now };
// Broadcast AP Update(Note) to followers (best-effort)
try {
const federation = pluginOptions.federation;
const handle = pluginOptions.handle || "user";
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
if (federation) {
const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl });
const actorUri = ctx.getActorUri(handle);
const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
const followersUri = ctx.getFollowersUri(handle);
const note = new Note({
id: new URL(postUrl),
attributedTo: actorUri,
content: updatedContent.html || updatedContent.text || statusText,
summary: newSummary || null,
sensitive: newSensitive,
published: item.published ? new Date(item.published) : null,
updated: new Date(now),
to: publicAddress,
cc: followersUri,
});
const updateActivity = new Update({
actor: actorUri,
object: note,
to: publicAddress,
cc: followersUri,
});
await ctx.sendActivity({ identifier: handle }, "followers", updateActivity, {
preferSharedInbox: true,
orderingKey: postUrl,
});
console.info(`[Mastodon API] Sent Update(Note) for ${postUrl}`);
}
} catch (err) {
console.warn(`[Mastodon API] AP Update broadcast failed for ${postUrl}: ${err.message}`);
}
const interactionState = await loadItemInteractions(collections, updatedItem);
res.json(serializeStatus(updatedItem, { baseUrl, ...interactionState }));
} catch (error) {
next(error);
}
});
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {