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/unreblog — unboost a post
|
||||||
* POST /api/v1/statuses/:id/bookmark — bookmark a post
|
* POST /api/v1/statuses/:id/bookmark — bookmark a post
|
||||||
* POST /api/v1/statuses/:id/unbookmark — remove bookmark
|
* 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 crypto from "node:crypto";
|
||||||
import express from "express";
|
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 { ObjectId } from "mongodb";
|
||||||
import { serializeStatus } from "../entities/status.js";
|
import { serializeStatus } from "../entities/status.js";
|
||||||
import { decodeCursor } from "../helpers/pagination.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 ─────────────────────────────────
|
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
|
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user