From 7c9318fa0811f2937e1daf621fc9bcd0e1b8aee5 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 16:02:50 +0200 Subject: [PATCH] feat: enhance Mastodon API layer with media upload, status editing, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix status delete bug (undefined objectId → item._id) - Add media upload endpoints (POST /api/v1/media, /api/v2/media) proxying to Micropub media endpoint - Wire media_ids into status creation for photo/video/audio attachments - Add Idempotency-Key header support for status creation - Add status edit (PUT /api/v1/statuses/:id) with edit history - Add accounts/search endpoint for @mention autocomplete - Add dedicated status card endpoint (GET /api/v1/statuses/:id/card) - Replace favourited_by/reblogged_by stubs with real interaction data - Add domain blocks CRUD (POST/DELETE /api/v1/domain_blocks) - Upgrade AP compose UI with EasyMDE editor, file-input, and tag-input components - Add hashtag follow/unfollow endpoints - Implement filters v2 CRUD (/api/v2/filters) - Add update_credentials endpoint (PATCH, text fields) - Add MongoDB collections: ap_media, ap_status_edits, ap_idempotency, ap_filters, ap_filter_keywords - Bump version to 3.11.0 --- index.js | 18 ++ lib/controllers/compose.js | 18 +- lib/init-indexes.js | 32 +++ lib/mastodon/entities/status.js | 2 +- lib/mastodon/router.js | 2 + lib/mastodon/routes/accounts.js | 95 +++++++++ lib/mastodon/routes/filters.js | 216 +++++++++++++++++++ lib/mastodon/routes/media.js | 256 +++++++++++++++++++++-- lib/mastodon/routes/statuses.js | 360 +++++++++++++++++++++++++++++++- lib/mastodon/routes/stubs.js | 114 +++++++++- package-lock.json | 175 +++++++++++++++- package.json | 5 +- views/activitypub-compose.njk | 40 +++- 13 files changed, 1281 insertions(+), 52 deletions(-) create mode 100644 lib/mastodon/routes/filters.js 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, "
"); + updateFields["content.text"] = statusText; + updateFields["content.html"] = contentHtml; + } + if (spoilerText !== undefined) updateFields.summary = spoilerText; + if (sensitive !== undefined) + updateFields.sensitive = sensitive === "true" || sensitive === true; + if (language !== undefined) updateFields.language = language; + updateFields.updatedAt = new Date().toISOString(); + + await collections.ap_timeline.updateOne( + { _id: item._id }, + { $set: updateFields }, + ); + + // Reload and serialize + const updated = await collections.ap_timeline.findOne({ + _id: item._id, + }); + const { serializeStatus, setLocalIdentity } = await import( + "../entities/status.js" + ); + const handle = pluginOptions.actor?.handle || ""; + setLocalIdentity(localPublicationUrl, handle); + + const serialized = serializeStatus(updated, { baseUrl }); + res.json(serialized); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/statuses/:id/history ─────────────────────────────────────── + +router.get("/api/v1/statuses/:id/history", tokenRequired, scopeRequired("read", "read:statuses"), 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 item = await findTimelineItemById(collections.ap_timeline, req.params.id); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const edits = collections.ap_status_edits + ? await collections.ap_status_edits + .find({ statusId: req.params.id }) + .sort({ editedAt: 1 }) + .toArray() + : []; + + const { serializeAccount } = await import("../entities/account.js"); + const localPublicationUrl = pluginOptions.publicationUrl || baseUrl; + const handle = pluginOptions.actor?.handle || ""; + + const accountObj = item.author + ? serializeAccount(item.author, { + baseUrl, + isLocal: item.author.url === localPublicationUrl, + handle, + }) + : null; + + // Build history: each edit snapshot + current version as latest + const history = edits.map((edit) => ({ + content: edit.content?.html || edit.content?.text || "", + spoiler_text: edit.summary || "", + sensitive: edit.sensitive || false, + created_at: edit.editedAt, + account: accountObj, + media_attachments: [], + emojis: [], + })); + + // Add current version as the latest entry + history.push({ + content: item.content?.html || item.content?.text || "", + spoiler_text: item.summary || "", + sensitive: item.sensitive || false, + created_at: item.updatedAt || item.published || item.createdAt, + account: accountObj, + media_attachments: [], + emojis: [], + }); + + res.json(history); + } catch (error) { + next(error); + } +}); + // ─── GET /api/v1/statuses/:id/favourited_by ───────────────────────────────── -router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => { - // Stub — we don't track who favourited remotely - res.json([]); +router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + const item = await findTimelineItemById( + collections.ap_timeline, + req.params.id, + ); + if (!item) return res.status(404).json({ error: "Record not found" }); + + const uid = item.uid || item.url; + if (!uid || !collections.ap_interactions) return res.json([]); + + const interactions = await collections.ap_interactions + .find({ objectUrl: uid, type: "like" }) + .limit(40) + .toArray(); + + const { serializeAccount } = await import("../entities/account.js"); + const accounts = interactions + .filter((i) => i.actorUrl || i.actorName) + .map((i) => + serializeAccount( + { + url: i.actorUrl, + name: i.actorName || "", + handle: i.actorHandle || "", + }, + { baseUrl, isLocal: false }, + ), + ); + + res.json(accounts); + } catch (error) { + next(error); + } }); // ─── GET /api/v1/statuses/:id/reblogged_by ────────────────────────────────── -router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => { - // Stub — we don't track who boosted remotely - res.json([]); +router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + const item = await findTimelineItemById( + collections.ap_timeline, + req.params.id, + ); + if (!item) return res.status(404).json({ error: "Record not found" }); + + const uid = item.uid || item.url; + if (!uid || !collections.ap_interactions) return res.json([]); + + const interactions = await collections.ap_interactions + .find({ objectUrl: uid, type: "boost" }) + .limit(40) + .toArray(); + + const { serializeAccount } = await import("../entities/account.js"); + const accounts = interactions + .filter((i) => i.actorUrl || i.actorName) + .map((i) => + serializeAccount( + { + url: i.actorUrl, + name: i.actorName || "", + handle: i.actorHandle || "", + }, + { baseUrl, isLocal: false }, + ), + ); + + res.json(accounts); + } catch (error) { + next(error); + } }); // ─── POST /api/v1/statuses/:id/favourite ──────────────────────────────────── @@ -493,6 +810,31 @@ router.post("/api/v1/statuses/:id/unbookmark", tokenRequired, scopeRequired("wri } }); +// ─── GET /api/v1/statuses/:id/card ────────────────────────────────────────── + +router.get("/api/v1/statuses/:id/card", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const item = await findTimelineItemById( + collections.ap_timeline, + req.params.id, + ); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const { serializeCard } = await import("../entities/status.js"); + const card = serializeCard(item.linkPreviews?.[0]); + if (!card) { + return res.json({}); + } + + res.json(card); + } catch (error) { + next(error); + } +}); + // ─── Helpers ───────────────────────────────────────────────────────────────── /** diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js index 8aa063a..20de135 100644 --- a/lib/mastodon/routes/stubs.js +++ b/lib/mastodon/routes/stubs.js @@ -22,6 +22,8 @@ import express from "express"; import { serializeStatus } from "../entities/status.js"; import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js"; import { getFollowedTagsWithState } from "../../storage/followed-tags.js"; +import { tokenRequired } from "../middleware/token-required.js"; +import { scopeRequired } from "../middleware/scope-required.js"; const router = express.Router(); // eslint-disable-line new-cap @@ -110,16 +112,6 @@ router.get("/api/v1/custom_emojis", (req, res) => { res.json([]); }); -// ─── Filters (v2) ─────────────────────────────────────────────────────────── - -router.get("/api/v2/filters", (req, res) => { - res.json([]); -}); - -router.get("/api/v1/filters", (req, res) => { - res.json([]); -}); - // ─── Lists ────────────────────────────────────────────────────────────────── router.get("/api/v1/lists", (req, res) => { @@ -302,6 +294,66 @@ router.get("/api/v1/followed_tags", async (req, res, next) => { } }); +// ─── GET /api/v1/tags/:id ─────────────────────────────────────────────────── + +router.get("/api/v1/tags/:id", async (req, res) => { + const collections = req.app.locals.mastodonCollections; + const tag = req.params.id.toLowerCase().replace(/^#/, ""); + let following = false; + + if (collections.ap_followed_tags) { + const doc = await collections.ap_followed_tags.findOne({ tag }); + following = !!doc; + } + + res.json({ + name: tag, + url: `${req.protocol}://${req.get("host")}/tags/${tag}`, + history: [], + following, + }); +}); + +// ─── POST /api/v1/tags/:id/follow ────────────────────────────────────────── + +router.post("/api/v1/tags/:id/follow", tokenRequired, scopeRequired("write", "write:follows"), async (req, res) => { + const collections = req.app.locals.mastodonCollections; + const tag = req.params.id.toLowerCase().replace(/^#/, ""); + + if (collections.ap_followed_tags) { + await collections.ap_followed_tags.updateOne( + { tag }, + { $setOnInsert: { tag, createdAt: new Date().toISOString() } }, + { upsert: true }, + ); + } + + res.json({ + name: tag, + url: `${req.protocol}://${req.get("host")}/tags/${tag}`, + history: [], + following: true, + }); +}); + +// ─── POST /api/v1/tags/:id/unfollow ──────────────────────────────────────── + +router.post("/api/v1/tags/:id/unfollow", tokenRequired, scopeRequired("write", "write:follows"), async (req, res) => { + const collections = req.app.locals.mastodonCollections; + const tag = req.params.id.toLowerCase().replace(/^#/, ""); + + if (collections.ap_followed_tags) { + await collections.ap_followed_tags.deleteOne({ tag }); + } + + res.json({ + name: tag, + url: `${req.protocol}://${req.get("host")}/tags/${tag}`, + history: [], + following: false, + }); +}); + // ─── Suggestions ──────────────────────────────────────────────────────────── router.get("/api/v2/suggestions", (req, res) => { @@ -347,6 +399,48 @@ router.get("/api/v1/domain_blocks", async (req, res) => { } }); +// ─── POST /api/v1/domain_blocks ───────────────────────────────────────────── + +router.post("/api/v1/domain_blocks", tokenRequired, scopeRequired("write", "write:blocks"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const domain = req.body.domain?.trim(); + + if (!domain) { + return res.status(422).json({ error: "domain is required" }); + } + + if (collections.ap_blocked_servers) { + await collections.ap_blocked_servers.updateOne( + { hostname: domain }, + { $setOnInsert: { hostname: domain, createdAt: new Date().toISOString() } }, + { upsert: true }, + ); + } + + res.json({}); + } catch (error) { + next(error); + } +}); + +// ─── DELETE /api/v1/domain_blocks ─────────────────────────────────────────── + +router.delete("/api/v1/domain_blocks", tokenRequired, scopeRequired("write", "write:blocks"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const domain = req.body.domain?.trim(); + + if (domain && collections.ap_blocked_servers) { + await collections.ap_blocked_servers.deleteOne({ hostname: domain }); + } + + res.json({}); + } catch (error) { + next(error); + } +}); + // ─── Endorsements ─────────────────────────────────────────────────────────── router.get("/api/v1/endorsements", (req, res) => { diff --git a/package-lock.json b/package-lock.json index 72fe5a0..f988de5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,23 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.9.4", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.9.4", + "version": "3.11.0", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", "@fedify/fedify": "^2.1.0", "@fedify/redis": "^2.1.0", "@js-temporal/polyfill": "^0.5.0", + "@rmdes/indiekit-startup-gate": "^1.0.0", "express": "^5.0.0", "express-rate-limit": "^7.5.1", "ioredis": "^5.9.3", + "multer": "^2.1.1", "sanitize-html": "^2.13.1", "unfurl.js": "^6.4.0" }, @@ -1396,6 +1398,12 @@ "node": ">=22" } }, + "node_modules/@rmdes/indiekit-startup-gate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rmdes/indiekit-startup-gate/-/indiekit-startup-gate-1.0.0.tgz", + "integrity": "sha512-LrfSjTN9Ay4RiJH5xSvsvOEs7Zqw/GCC9+FhF7S6Ij8eDXpJOKQeHshAhzsqSmP/wksAyq0TIhqXZAPJXM+Tcg==", + "license": "MIT" + }, "node_modules/@sindresorhus/slugify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz", @@ -1510,6 +1518,12 @@ "node": ">= 0.6" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1572,6 +1586,23 @@ "node": ">=20.19.0" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/byte-encodings": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/byte-encodings/-/byte-encodings-1.0.11.tgz", @@ -1720,6 +1751,21 @@ "node": ">= 6" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -2968,6 +3014,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3294,6 +3402,20 @@ "node": ">=18" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -3331,6 +3453,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3572,6 +3714,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/structured-field-values": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/structured-field-values/-/structured-field-values-2.0.4.tgz", @@ -3623,6 +3782,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typo-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz", @@ -3713,6 +3878,12 @@ "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index db396cb..5f057ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.6", + "version": "3.11.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", @@ -38,13 +38,14 @@ }, "dependencies": { "@fedify/debugger": "^2.1.0", - "@rmdes/indiekit-startup-gate": "^1.0.0", "@fedify/fedify": "^2.1.0", "@fedify/redis": "^2.1.0", "@js-temporal/polyfill": "^0.5.0", + "@rmdes/indiekit-startup-gate": "^1.0.0", "express": "^5.0.0", "express-rate-limit": "^7.5.1", "ioredis": "^5.9.3", + "multer": "^2.1.1", "sanitize-html": "^2.13.1", "unfurl.js": "^6.4.0" }, diff --git a/views/activitypub-compose.njk b/views/activitypub-compose.njk index 8d43a3f..a8d4047 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -1,6 +1,9 @@ {% extends "layouts/ap-reader.njk" %} {% from "heading/macro.njk" import heading with context %} +{% from "textarea/macro.njk" import textarea with context %} +{% from "file-input/macro.njk" import fileInput with context %} +{% from "tag-input/macro.njk" import tagInput with context %} {% block readercontent %} {# Reply context — show the post being replied to #} @@ -21,7 +24,7 @@ {% endif %} -
+ {% if replyTo %} @@ -39,12 +42,37 @@ style="display: none"> - {# Content textarea #} + {# Rich content editor (EasyMDE with media browser) #}
- + {{ textarea({ + id: "content", + name: "content", + label: { text: __("activitypub.compose.contentLabel") if __("activitypub.compose.contentLabel") else "Content" }, + attributes: { + rows: "8", + required: "true" + }, + endpoint: mediaEndpoint + }) }} +
+ + {# Featured image #} +
+ {{ fileInput({ + id: "photo", + name: "photo", + label: { text: __("activitypub.compose.photoLabel") if __("activitypub.compose.photoLabel") else "Photo" }, + endpoint: mediaEndpoint + }) }} +
+ + {# Tags / categories #} +
+ {{ tagInput({ + id: "category", + name: "category", + label: { text: __("activitypub.compose.tagsLabel") if __("activitypub.compose.tagsLabel") else "Tags" } + }) }}
{# Visibility #}