diff --git a/index.js b/index.js index 2c2e249..15fa500 100644 --- a/index.js +++ b/index.js @@ -959,6 +959,15 @@ export default class ActivityPubEndpoint { Indiekit.addCollection("ap_markers"); // Tombstones for soft-deleted posts (FEP-4f05) Indiekit.addCollection("ap_tombstones"); + // Media attachments (Mastodon API upload) + Indiekit.addCollection("ap_media"); + // Status edit history + Indiekit.addCollection("ap_status_edits"); + // Idempotency keys for Mastodon API + Indiekit.addCollection("ap_idempotency"); + // Filters and filter keywords + Indiekit.addCollection("ap_filters"); + Indiekit.addCollection("ap_filter_keywords"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; @@ -997,6 +1006,15 @@ export default class ActivityPubEndpoint { ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"), ap_markers: indiekitCollections.get("ap_markers"), ap_tombstones: indiekitCollections.get("ap_tombstones"), + // Media attachments (Mastodon API upload) + ap_media: indiekitCollections.get("ap_media"), + // Status edit history + ap_status_edits: indiekitCollections.get("ap_status_edits"), + // Idempotency keys for Mastodon API + ap_idempotency: indiekitCollections.get("ap_idempotency"), + // Filters and filter keywords + ap_filters: indiekitCollections.get("ap_filters"), + ap_filter_keywords: indiekitCollections.get("ap_filter_keywords"), get posts() { return indiekitCollections.get("posts"); }, diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index 5ccac1c..05da1d3 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -144,6 +144,7 @@ export function composeController(mountPath, plugin) { syndicationTargets, csrfToken, mountPath, + mediaEndpoint: application.mediaEndpoint || "", }); } catch (error) { next(error); @@ -167,7 +168,7 @@ export function submitComposeController(mountPath, plugin) { } const { application } = request.app.locals; - const { content, visibility, summary } = request.body; + const { content, visibility, summary, photo, category } = request.body; const cwEnabled = request.body["cw-enabled"]; const inReplyTo = request.body["in-reply-to"]; const syndicateTo = request.body["mp-syndicate-to"]; @@ -228,6 +229,21 @@ export function submitComposeController(mountPath, plugin) { } } + // Photo (from file-input component — already a URL from media endpoint) + if (photo && photo.trim()) { + micropubData.append("photo", photo.trim()); + } + + // Tags / categories + if (category) { + const tags = Array.isArray(category) + ? category + : category.split(",").map((t) => t.trim()).filter(Boolean); + for (const tag of tags) { + micropubData.append("category[]", tag); + } + } + console.info( `[ActivityPub] Compose Micropub submission:`, JSON.stringify({ diff --git a/lib/init-indexes.js b/lib/init-indexes.js index 607f287..cad13dd 100644 --- a/lib/init-indexes.js +++ b/lib/init-indexes.js @@ -250,6 +250,38 @@ export function createIndexes(collections, options) { { url: 1 }, { unique: true, background: true }, ); + + // Media attachments (Mastodon API upload) + collections.ap_media?.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 86400, background: true }, + ); + + // Status edit history + collections.ap_status_edits?.createIndex( + { statusId: 1, editedAt: 1 }, + { background: true }, + ); + + // Idempotency keys (auto-expire after 1 hour) + collections.ap_idempotency?.createIndex( + { key: 1 }, + { unique: true, background: true }, + ); + collections.ap_idempotency?.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 3600, background: true }, + ); + + // Filters + collections.ap_filters?.createIndex( + { createdAt: 1 }, + { background: true }, + ); + collections.ap_filter_keywords?.createIndex( + { filterId: 1 }, + { background: true }, + ); } catch { // Index creation failed — collections not yet available. // Indexes already exist from previous startups; non-fatal. diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index 069b92a..db476ce 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -246,7 +246,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo /** * Serialize a linkPreview object as a Mastodon PreviewCard. */ -function serializeCard(preview) { +export function serializeCard(preview) { if (!preview) return null; return { diff --git a/lib/mastodon/router.js b/lib/mastodon/router.js index 12bb057..bd498a9 100644 --- a/lib/mastodon/router.js +++ b/lib/mastodon/router.js @@ -20,6 +20,7 @@ import timelinesRouter from "./routes/timelines.js"; import notificationsRouter from "./routes/notifications.js"; import searchRouter from "./routes/search.js"; import mediaRouter from "./routes/media.js"; +import filtersRouter from "./routes/filters.js"; import stubsRouter from "./routes/stubs.js"; // Rate limiters for different endpoint categories. @@ -118,6 +119,7 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) { router.use(notificationsRouter); router.use(searchRouter); router.use(mediaRouter); + router.use(filtersRouter); router.use(stubsRouter); // ─── Catch-all for unimplemented endpoints ────────────────────────────── diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 932f2e3..8486110 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -153,6 +153,61 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => { } }); +// ─── GET /api/v1/accounts/search ──────────────────────────────────────────── +// Used by clients for @mention autocomplete in compose box. + +router.get("/api/v1/accounts/search", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const query = req.query.q?.trim(); + const limit = Math.min(Number.parseInt(req.query.limit, 10) || 10, 40); + + if (!query) { + return res.json([]); + } + + // Escape regex special characters + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escaped, "i"); + + const results = new Map(); // dedupe by URL + + // Search followers + if (collections.ap_followers) { + const followers = await collections.ap_followers + .find({ + $or: [{ name: regex }, { handle: regex }, { actorUrl: regex }], + }) + .limit(limit) + .toArray(); + for (const f of followers) results.set(f.actorUrl, f); + } + + // Search following + if (results.size < limit && collections.ap_following) { + const following = await collections.ap_following + .find({ + $or: [{ name: regex }, { handle: regex }, { actorUrl: regex }], + }) + .limit(limit - results.size) + .toArray(); + for (const f of following) results.set(f.actorUrl, f); + } + + const { serializeAccount } = await import("../entities/account.js"); + const accounts = [...results.values()] + .slice(0, limit) + .map((actor) => + serializeAccount(actor, { baseUrl, isLocal: false }), + ); + + res.json(accounts); + } catch (error) { + next(error); + } +}); + // ─── GET /api/v1/accounts/relationships ────────────────────────────────────── // MUST be before /accounts/:id to prevent Express matching "relationships" as :id @@ -228,6 +283,46 @@ router.get("/api/v1/accounts/familiar_followers", tokenRequired, scopeRequired(" res.json(ids.map((id) => ({ id, accounts: [] }))); }); +// ─── PATCH /api/v1/accounts/update_credentials ────────────────────────────── + +router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired("write", "write:accounts"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + const update = {}; + if (req.body.display_name !== undefined) update.name = req.body.display_name; + if (req.body.note !== undefined) update.summary = req.body.note; + if (req.body.fields_attributes) { + update.attachments = Object.values(req.body.fields_attributes).map( + (f) => ({ + name: f.name, + value: f.value, + }), + ); + } + + if (Object.keys(update).length > 0 && collections.ap_profile) { + await collections.ap_profile.updateOne({}, { $set: update }); + } + + // Return updated credential account + const profile = collections.ap_profile + ? await collections.ap_profile.findOne({}) + : {}; + + const { serializeCredentialAccount } = await import( + "../entities/account.js" + ); + res.json( + await serializeCredentialAccount(profile, { baseUrl, collections }), + ); + } catch (error) { + next(error); + } +}); + // ─── GET /api/v1/accounts/:id ──────────────────────────────────────────────── router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => { diff --git a/lib/mastodon/routes/filters.js b/lib/mastodon/routes/filters.js new file mode 100644 index 0000000..f1a6c09 --- /dev/null +++ b/lib/mastodon/routes/filters.js @@ -0,0 +1,216 @@ +/** + * Filter endpoints for Mastodon Client API v2. + */ +import express from "express"; +import { ObjectId } from "mongodb"; +import { tokenRequired } from "../middleware/token-required.js"; +import { scopeRequired } from "../middleware/scope-required.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +/** + * Serialize a filter document with its keywords. + */ +function serializeFilter(filter, keywords = []) { + return { + id: filter._id.toString(), + title: filter.title || "", + context: filter.context || [], + filter_action: filter.filterAction || "warn", + expires_at: filter.expiresAt || null, + keywords: keywords.map((kw) => ({ + id: kw._id.toString(), + keyword: kw.keyword, + whole_word: kw.wholeWord ?? true, + })), + statuses: [], + }; +} + +// ─── GET /api/v2/filters ──────────────────────────────────────────────────── + +router.get("/api/v2/filters", tokenRequired, scopeRequired("read", "read:filters"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + if (!collections.ap_filters) return res.json([]); + + const filters = await collections.ap_filters.find({}).toArray(); + const result = []; + + for (const filter of filters) { + const keywords = collections.ap_filter_keywords + ? await collections.ap_filter_keywords + .find({ filterId: filter._id }) + .toArray() + : []; + result.push(serializeFilter(filter, keywords)); + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v2/filters ─────────────────────────────────────────────────── + +router.post("/api/v2/filters", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + if (!collections.ap_filters) { + return res.status(500).json({ error: "Filters not available" }); + } + + const { + title, + context, + filter_action: filterAction = "warn", + expires_in: expiresIn, + keywords_attributes: keywordsAttributes, + } = req.body; + + if (!title) { + return res.status(422).json({ error: "title is required" }); + } + + const expiresAt = expiresIn + ? new Date(Date.now() + Number.parseInt(expiresIn, 10) * 1000).toISOString() + : null; + + const filterDoc = { + title, + context: Array.isArray(context) ? context : [context].filter(Boolean), + filterAction, + expiresAt, + createdAt: new Date().toISOString(), + }; + + const result = await collections.ap_filters.insertOne(filterDoc); + filterDoc._id = result.insertedId; + + // Insert keywords if provided + const keywords = []; + if (keywordsAttributes && collections.ap_filter_keywords) { + const attrs = Array.isArray(keywordsAttributes) + ? keywordsAttributes + : Object.values(keywordsAttributes); + for (const attr of attrs) { + if (attr.keyword) { + const kwDoc = { + filterId: filterDoc._id, + keyword: attr.keyword, + wholeWord: attr.whole_word !== "false" && attr.whole_word !== false, + }; + const kwResult = await collections.ap_filter_keywords.insertOne(kwDoc); + kwDoc._id = kwResult.insertedId; + keywords.push(kwDoc); + } + } + } + + res.json(serializeFilter(filterDoc, keywords)); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v2/filters/:id ──────────────────────────────────────────────── + +router.get("/api/v2/filters/:id", tokenRequired, scopeRequired("read", "read:filters"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + let filter; + try { + filter = await collections.ap_filters?.findOne({ + _id: new ObjectId(req.params.id), + }); + } catch { /* invalid ObjectId */ } + + if (!filter) { + return res.status(404).json({ error: "Record not found" }); + } + + const keywords = collections.ap_filter_keywords + ? await collections.ap_filter_keywords + .find({ filterId: filter._id }) + .toArray() + : []; + + res.json(serializeFilter(filter, keywords)); + } catch (error) { + next(error); + } +}); + +// ─── PUT /api/v2/filters/:id ──────────────────────────────────────────────── + +router.put("/api/v2/filters/:id", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + let filter; + try { + filter = await collections.ap_filters?.findOne({ + _id: new ObjectId(req.params.id), + }); + } catch { /* invalid ObjectId */ } + + if (!filter) { + return res.status(404).json({ error: "Record not found" }); + } + + const update = {}; + if (req.body.title !== undefined) update.title = req.body.title; + if (req.body.context !== undefined) { + update.context = Array.isArray(req.body.context) + ? req.body.context + : [req.body.context].filter(Boolean); + } + if (req.body.filter_action !== undefined) update.filterAction = req.body.filter_action; + if (req.body.expires_in !== undefined) { + update.expiresAt = req.body.expires_in + ? new Date(Date.now() + Number.parseInt(req.body.expires_in, 10) * 1000).toISOString() + : null; + } + + if (Object.keys(update).length > 0) { + await collections.ap_filters.updateOne( + { _id: filter._id }, + { $set: update }, + ); + Object.assign(filter, update); + } + + const keywords = collections.ap_filter_keywords + ? await collections.ap_filter_keywords + .find({ filterId: filter._id }) + .toArray() + : []; + + res.json(serializeFilter(filter, keywords)); + } catch (error) { + next(error); + } +}); + +// ─── DELETE /api/v2/filters/:id ───────────────────────────────────────────── + +router.delete("/api/v2/filters/:id", tokenRequired, scopeRequired("write", "write:filters"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + let filterId; + try { + filterId = new ObjectId(req.params.id); + } catch { + return res.status(404).json({ error: "Record not found" }); + } + + await collections.ap_filters?.deleteOne({ _id: filterId }); + await collections.ap_filter_keywords?.deleteMany({ filterId }); + + res.json({}); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/lib/mastodon/routes/media.js b/lib/mastodon/routes/media.js index 31e0bee..8ade77e 100644 --- a/lib/mastodon/routes/media.js +++ b/lib/mastodon/routes/media.js @@ -1,45 +1,259 @@ /** * Media endpoints for Mastodon Client API. * - * POST /api/v2/media — upload media attachment (stub — returns 422 until storage is configured) - * POST /api/v1/media — legacy upload endpoint (redirects to v2) - * GET /api/v1/media/:id — get media attachment status + * POST /api/v2/media — upload media attachment via Micropub media endpoint + * POST /api/v1/media — legacy upload (same as v2) + * GET /api/v1/media/:id — get media attachment metadata * PUT /api/v1/media/:id — update media metadata (description/focus) */ import express from "express"; +import multer from "multer"; +import { ObjectId } from "mongodb"; import { tokenRequired } from "../middleware/token-required.js"; import { scopeRequired } from "../middleware/scope-required.js"; const router = express.Router(); // eslint-disable-line new-cap +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 40 * 1024 * 1024 }, +}); + +/** + * Determine Mastodon media type from MIME type. + */ +function mediaType(mimeType) { + if (mimeType?.startsWith("image/")) return "image"; + if (mimeType?.startsWith("video/")) return "video"; + if (mimeType?.startsWith("audio/")) return "audio"; + return "unknown"; +} + +/** + * Serialize an ap_media document to a Mastodon MediaAttachment object. + */ +function serializeMediaAttachment(doc) { + return { + id: doc._id.toString(), + type: mediaType(doc.mimeType), + url: doc.url, + preview_url: doc.url, + remote_url: null, + text_url: null, + meta: doc.focus + ? { + focus: { + x: Number.parseFloat(doc.focus.split(",")[0]) || 0, + y: Number.parseFloat(doc.focus.split(",")[1]) || 0, + }, + } + : null, + description: doc.description || "", + blurhash: null, + }; +} + +/** + * Upload file to the Micropub media endpoint. + * Returns the URL from the Location header. + */ +async function uploadToMediaEndpoint(file, application, token) { + const mediaEndpoint = application.mediaEndpoint; + if (!mediaEndpoint) { + throw new Error("Media endpoint not configured"); + } + + const mediaUrl = mediaEndpoint.startsWith("http") + ? mediaEndpoint + : new URL(mediaEndpoint, application.url).href; + + const formData = new FormData(); + const blob = new Blob([file.buffer], { type: file.mimetype }); + formData.append("file", blob, file.originalname); + + const response = await fetch(mediaUrl, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Media endpoint returned ${response.status}: ${body}`); + } + + const location = response.headers.get("Location"); + if (!location) { + throw new Error("Media endpoint did not return a Location header"); + } + + return location; +} // ─── POST /api/v2/media ───────────────────────────────────────────────────── -router.post("/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => { - // Media upload requires multer/multipart handling + storage backend. - // For now, return 422 so clients show a user-friendly error. - res.status(422).json({ - error: "Media uploads are not yet supported on this server", - }); -}); +router.post( + "/api/v2/media", + tokenRequired, + scopeRequired("write", "write:media"), + upload.single("file"), + async (req, res, next) => { + try { + const { application } = req.app.locals; + const collections = req.app.locals.mastodonCollections; + const token = + req.session?.access_token || req.mastodonToken?.accessToken; + + if (!req.file) { + return res.status(422).json({ error: "No file provided" }); + } + + if (!token) { + return res + .status(401) + .json({ error: "Authentication required for media upload" }); + } + + const fileUrl = await uploadToMediaEndpoint( + req.file, + application, + token, + ); + + const doc = { + url: fileUrl, + description: req.body.description || "", + focus: req.body.focus || null, + mimeType: req.file.mimetype, + createdAt: new Date(), + }; + + const result = await collections.ap_media.insertOne(doc); + doc._id = result.insertedId; + + res.json(serializeMediaAttachment(doc)); + } catch (error) { + next(error); + } + }, +); // ─── POST /api/v1/media (legacy) ──────────────────────────────────────────── -router.post("/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => { - res.status(422).json({ - error: "Media uploads are not yet supported on this server", - }); -}); +router.post( + "/api/v1/media", + tokenRequired, + scopeRequired("write", "write:media"), + upload.single("file"), + async (req, res, next) => { + try { + const { application } = req.app.locals; + const collections = req.app.locals.mastodonCollections; + const token = + req.session?.access_token || req.mastodonToken?.accessToken; + + if (!req.file) { + return res.status(422).json({ error: "No file provided" }); + } + + if (!token) { + return res + .status(401) + .json({ error: "Authentication required for media upload" }); + } + + const fileUrl = await uploadToMediaEndpoint( + req.file, + application, + token, + ); + + const doc = { + url: fileUrl, + description: req.body.description || "", + focus: req.body.focus || null, + mimeType: req.file.mimetype, + createdAt: new Date(), + }; + + const result = await collections.ap_media.insertOne(doc); + doc._id = result.insertedId; + + res.json(serializeMediaAttachment(doc)); + } catch (error) { + next(error); + } + }, +); // ─── GET /api/v1/media/:id ────────────────────────────────────────────────── -router.get("/api/v1/media/:id", tokenRequired, scopeRequired("read", "read:statuses"), (req, res) => { - res.status(404).json({ error: "Record not found" }); -}); +router.get( + "/api/v1/media/:id", + tokenRequired, + scopeRequired("read", "read:statuses"), + async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + let doc; + try { + doc = await collections.ap_media.findOne({ + _id: new ObjectId(req.params.id), + }); + } catch { + /* invalid ObjectId */ + } + + if (!doc) { + return res.status(404).json({ error: "Record not found" }); + } + + res.json(serializeMediaAttachment(doc)); + } catch (error) { + next(error); + } + }, +); // ─── PUT /api/v1/media/:id ────────────────────────────────────────────────── -router.put("/api/v1/media/:id", tokenRequired, scopeRequired("write", "write:media"), (req, res) => { - res.status(404).json({ error: "Record not found" }); -}); +router.put( + "/api/v1/media/:id", + tokenRequired, + scopeRequired("write", "write:media"), + async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + let doc; + try { + doc = await collections.ap_media.findOne({ + _id: new ObjectId(req.params.id), + }); + } catch { + /* invalid ObjectId */ + } + + if (!doc) { + return res.status(404).json({ error: "Record not found" }); + } + + const update = {}; + if (req.body.description !== undefined) + update.description = req.body.description; + if (req.body.focus !== undefined) update.focus = req.body.focus; + + if (Object.keys(update).length > 0) { + await collections.ap_media.updateOne( + { _id: doc._id }, + { $set: update }, + ); + Object.assign(doc, update); + } + + res.json(serializeMediaAttachment(doc)); + } catch (error) { + next(error); + } + }, +); export default router; diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 6c59ca2..52f0601 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -4,7 +4,9 @@ * 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 @@ -142,6 +144,19 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta 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, @@ -165,6 +180,28 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta } } + // 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; @@ -204,6 +241,20 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta 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({ value: 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"); @@ -220,7 +271,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta const profile = await collections.ap_profile.findOne({}); const handle = pluginOptions.handle || "user"; - res.json({ + const statusResponse = { id: String(Date.now()), created_at: new Date().toISOString(), content: `
${contentHtml}
`, @@ -263,7 +314,20 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta })), 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); } @@ -319,7 +383,7 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri } // Delete from timeline - await collections.ap_timeline.deleteOne({ _id: objectId }); + await collections.ap_timeline.deleteOne({ _id: item._id }); // Clean up interactions if (collections.ap_interactions && item.uid) { @@ -332,18 +396,271 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri } }); +// ─── 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?.accessToken; + if (token) { + const updatePayload = { + action: "update", + url: postUrl, + replace: {}, + }; + + if (statusText !== undefined) { + updatePayload.replace.content = [statusText]; + } + + 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, "