mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user