diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 612e4dc..a074e72 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -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) => {