From d52869a50b3104a5eddec1b5f35b1979626dad1d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 28 Mar 2026 22:18:24 +0100 Subject: [PATCH 01/37] feat: defer background tasks until host readiness signal --- index.js | 114 +++++++++++++++++++++++++++------------------------ package.json | 3 +- 2 files changed, 62 insertions(+), 55 deletions(-) diff --git a/index.js b/index.js index 9f43818..239140f 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ import express from "express"; +import { waitForReady } from "@rmdes/indiekit-startup-gate"; import { setupFederation, buildPersonActor } from "./lib/federation-setup.js"; import { createMastodonRouter } from "./lib/mastodon/router.js"; @@ -1096,19 +1097,6 @@ export default class ActivityPubEndpoint { // Register syndicator (appears in post editing UI) Indiekit.addSyndicator(this.syndicator); - // Start batch re-follow processor after federation settles - const refollowOptions = { - federation: this._federation, - collections: this._collections, - handle: this.options.actor.handle, - publicationUrl: this._publicationUrl, - }; - setTimeout(() => { - startBatchRefollow(refollowOptions).catch((error) => { - console.error("[ActivityPub] Batch refollow start failed:", error.message); - }); - }, 10_000); - // Run one-time migrations (idempotent — safe to run on every startup) console.info("[ActivityPub] Init: starting post-refollow setup"); runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => { @@ -1119,52 +1107,66 @@ export default class ActivityPubEndpoint { console.error("[ActivityPub] Migration separate-mentions failed:", error.message); }); - // Schedule timeline retention cleanup (runs on startup + every 24h) - if (this.options.timelineRetention > 0) { - scheduleCleanup(this._collections, this.options.timelineRetention); - } - - // Load server blocks into Redis for fast inbox checks - loadBlockedServersToRedis(this._collections).catch((error) => { - console.warn("[ActivityPub] Failed to load blocked servers to Redis:", error.message); - }); - - // Schedule proactive key refresh for stale follower keys (runs on startup + every 24h) + // Defer background workers until host is ready + const refollowOptions = { + federation: this._federation, + collections: this._collections, + handle: this.options.actor.handle, + publicationUrl: this._publicationUrl, + }; const keyRefreshHandle = this.options.actor.handle; const keyRefreshFederation = this._federation; const keyRefreshPubUrl = this._publicationUrl; - scheduleKeyRefresh( - this._collections, - () => keyRefreshFederation?.createContext(new URL(keyRefreshPubUrl), { - handle: keyRefreshHandle, - publicationUrl: keyRefreshPubUrl, - }), - keyRefreshHandle, - ); - - // Backfill ap_timeline from posts collection (idempotent, runs on every startup) - import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => { - // Delay to let MongoDB connections settle - setTimeout(() => { - backfillTimeline(this._collections).then(({ total, inserted, skipped }) => { - if (inserted > 0) { - console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`); - } - }).catch((error) => { - console.warn("[Mastodon API] Timeline backfill failed:", error.message); + this._stopGate = waitForReady( + () => { + // Start batch re-follow processor + startBatchRefollow(refollowOptions).catch((error) => { + console.error("[ActivityPub] Batch refollow start failed:", error.message); }); - }, 5000); - }); - // Start async inbox queue processor (processes one item every 3s) - console.info("[ActivityPub] Init: starting inbox queue processor"); - this._inboxProcessorInterval = startInboxProcessor( - this._collections, - () => this._federation?.createContext(new URL(this._publicationUrl), { - handle: this.options.actor.handle, - publicationUrl: this._publicationUrl, - }), - this.options.actor.handle, + // Schedule timeline retention cleanup (runs on startup + every 24h) + if (this.options.timelineRetention > 0) { + scheduleCleanup(this._collections, this.options.timelineRetention); + } + + // Load server blocks into Redis for fast inbox checks + loadBlockedServersToRedis(this._collections).catch((error) => { + console.warn("[ActivityPub] Failed to load blocked servers to Redis:", error.message); + }); + + // Schedule proactive key refresh for stale follower keys (runs on startup + every 24h) + scheduleKeyRefresh( + this._collections, + () => keyRefreshFederation?.createContext(new URL(keyRefreshPubUrl), { + handle: keyRefreshHandle, + publicationUrl: keyRefreshPubUrl, + }), + keyRefreshHandle, + ); + + // Backfill ap_timeline from posts collection (idempotent, runs on every startup) + import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => { + backfillTimeline(this._collections).then(({ total, inserted, skipped }) => { + if (inserted > 0) { + console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`); + } + }).catch((error) => { + console.warn("[Mastodon API] Timeline backfill failed:", error.message); + }); + }); + + // Start async inbox queue processor (processes one item every 3s) + console.info("[ActivityPub] Init: starting inbox queue processor"); + this._inboxProcessorInterval = startInboxProcessor( + this._collections, + () => this._federation?.createContext(new URL(this._publicationUrl), { + handle: this.options.actor.handle, + publicationUrl: this._publicationUrl, + }), + this.options.actor.handle, + ); + }, + { label: "ActivityPub" }, ); } @@ -1198,4 +1200,8 @@ export default class ActivityPubEndpoint { await ap_profile.insertOne(profile); } + + destroy() { + this._stopGate?.(); + } } diff --git a/package.json b/package.json index 1955000..a7b6b04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.3", + "version": "3.10.4", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", @@ -38,6 +38,7 @@ }, "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", From 7df2ef4437fc25d1b05e753cfe17231b00e5610d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 28 Mar 2026 22:25:37 +0100 Subject: [PATCH 02/37] fix: clear inbox processor interval on destroy --- index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/index.js b/index.js index 239140f..2c2e249 100644 --- a/index.js +++ b/index.js @@ -1203,5 +1203,8 @@ export default class ActivityPubEndpoint { destroy() { this._stopGate?.(); + if (this._inboxProcessorInterval) { + clearInterval(this._inboxProcessorInterval); + } } } From 7a219cacc0d7a11c01b7ee599708c5297d79bc02 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 28 Mar 2026 22:26:56 +0100 Subject: [PATCH 03/37] chore: bump version to 3.10.5 for destroy fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a7b6b04..339017a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.4", + "version": "3.10.5", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 6f2f2b7f8363040c59c8a20eaa0bc22e9302daeb Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 28 Mar 2026 22:52:13 +0100 Subject: [PATCH 04/37] fix: disable trustProxy validation in express-rate-limit Indiekit sets Express trust proxy to true globally (behind reverse proxy). express-rate-limit v7+ throws ERR_ERL_PERMISSIVE_TRUST_PROXY. Disable the check since the proxy (Cloudron nginx) is trusted infra. --- lib/mastodon/router.js | 8 +++++++- package.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/mastodon/router.js b/lib/mastodon/router.js index ffd1b0f..12bb057 100644 --- a/lib/mastodon/router.js +++ b/lib/mastodon/router.js @@ -22,13 +22,17 @@ import searchRouter from "./routes/search.js"; import mediaRouter from "./routes/media.js"; import stubsRouter from "./routes/stubs.js"; -// Rate limiters for different endpoint categories +// Rate limiters for different endpoint categories. +// validate.trustProxy disabled — Indiekit sets Express trust proxy to true +// (behind Cloudron/nginx), which express-rate-limit v7+ rejects as too +// permissive. The proxy is trusted infrastructure, not user-controlled. const apiLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 300, standardHeaders: true, legacyHeaders: false, message: { error: "Too many requests, please try again later" }, + validate: { trustProxy: false }, }); const authLimiter = rateLimit({ @@ -37,6 +41,7 @@ const authLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, message: { error: "Too many authentication attempts" }, + validate: { trustProxy: false }, }); const appRegistrationLimiter = rateLimit({ @@ -45,6 +50,7 @@ const appRegistrationLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, message: { error: "Too many app registrations" }, + validate: { trustProxy: false }, }); /** diff --git a/package.json b/package.json index 339017a..db396cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.5", + "version": "3.10.6", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From af2d4b8a0f00b703218bc68b63f1bf48a34ede07 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 28 Mar 2026 23:25:03 +0100 Subject: [PATCH 05/37] docs: document startup-gate usage in CLAUDE.md --- CLAUDE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 08da7a9..26d2361 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -589,6 +589,15 @@ On restart, `refollow:pending` entries are reset to `import` to prevent stale cl } ``` +## Startup Gate + +This plugin uses `@rmdes/indiekit-startup-gate` to defer background tasks until the host signals readiness (after Eleventy build completes). This prevents resource contention during the build. + +**Deferred:** `startBatchRefollow()`, `scheduleCleanup()`, `loadBlockedServersToRedis()`, `scheduleKeyRefresh()`, timeline backfill, `startInboxProcessor()` +**Immediate:** Routes, federation context, inbox HTTP handlers, `runSeparateMentionsMigration()` + +See workspace CLAUDE.md for the full startup-gate pattern. Any new background tasks added to this plugin MUST be wrapped in `waitForReady()`. Inbox routes MUST remain immediate — they receive inbound federation traffic regardless of build state. + ## Publishing Workflow 1. Edit code in this repo From 7c9318fa0811f2937e1daf621fc9bcd0e1b8aee5 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 16:02:50 +0200 Subject: [PATCH 06/37] 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 #} From 2e7024886b134d650fce667c791dfb7a9b9aed0e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 16:14:02 +0200 Subject: [PATCH 07/37] fix: correct macro API for textarea, file-input, and tag-input in compose UI - label is a string, not { text: "..." } - EasyMDE editor enabled via field.attributes (editor, editor-endpoint, editor-id, editor-image-upload) - file-input endpoint goes in field.attributes.endpoint - tag-input uses flat opts (label string, hint string) - Bump to 3.11.1 --- package-lock.json | 4 ++-- package.json | 2 +- views/activitypub-compose.njk | 36 +++++++++++++++++++++++------------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index f988de5..07704b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.0", + "version": "3.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.0", + "version": "3.11.1", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", diff --git a/package.json b/package.json index 5f057ae..c146009 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.0", + "version": "3.11.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-compose.njk b/views/activitypub-compose.njk index a8d4047..fec5b6a 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -45,33 +45,45 @@ {# 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 + label: "Content", + rows: 8, + field: { + attributes: { + editor: true, + "editor-endpoint": mediaEndpoint, + "editor-id": "ap-compose-content", + "editor-locale": application.locale if application else "en", + "editor-image-upload": true + } + } }) }}
{# Featured image #}
{{ fileInput({ - id: "photo", name: "photo", - label: { text: __("activitypub.compose.photoLabel") if __("activitypub.compose.photoLabel") else "Photo" }, - endpoint: mediaEndpoint + label: "Photo", + field: { + attributes: { + endpoint: mediaEndpoint + } + }, + accept: "image/*", + attributes: { + placeholder: "https://" + } }) }}
{# Tags / categories #}
{{ tagInput({ - id: "category", name: "category", - label: { text: __("activitypub.compose.tagsLabel") if __("activitypub.compose.tagsLabel") else "Tags" } + label: "Tags", + optional: true, + hint: "Separate with commas" }) }}
From b138efa8176e120d55d2ab6ebe63f2c58ca10deb Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 16:28:23 +0200 Subject: [PATCH 08/37] fix: skip body parsers for multipart/form-data requests express.json() and express.urlencoded() consume the request stream before multer can read it, causing "Unexpected end of form" on media upload. Skip these parsers when content-type is multipart/form-data. --- lib/mastodon/router.js | 15 ++++++++++++--- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/mastodon/router.js b/lib/mastodon/router.js index bd498a9..9cb25ca 100644 --- a/lib/mastodon/router.js +++ b/lib/mastodon/router.js @@ -67,9 +67,18 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) { // ─── Body parsers ─────────────────────────────────────────────────────── // Mastodon clients send JSON, form-urlencoded, and occasionally text/plain. - // These must be applied before route handlers. - router.use("/api", express.json()); - router.use("/api", express.urlencoded({ extended: true })); + // Skip multipart/form-data requests — multer handles those in media routes. + // If express.json/urlencoded consume the stream first, multer gets nothing. + const jsonParser = express.json(); + const urlencodedParser = express.urlencoded({ extended: true }); + router.use("/api", (req, res, next) => { + if (req.is("multipart/form-data")) return next(); + jsonParser(req, res, next); + }); + router.use("/api", (req, res, next) => { + if (req.is("multipart/form-data")) return next(); + urlencodedParser(req, res, next); + }); router.use("/oauth", express.json()); router.use("/oauth", express.urlencoded({ extended: true })); diff --git a/package-lock.json b/package-lock.json index 07704b3..b2262e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.1", + "version": "3.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.1", + "version": "3.11.2", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", diff --git a/package.json b/package.json index c146009..95fd27f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.1", + "version": "3.11.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 4c5b6032c18d5a8a5a5587eeaf5d41c3a13283a8 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 17:04:01 +0200 Subject: [PATCH 09/37] fix: replace multer with express-fileupload for media uploads Indiekit's express.js applies express-fileupload globally, which consumes the multipart stream before multer can read it, causing "Unexpected end of form". Use req.files (from express-fileupload) instead of req.file (from multer). Remove multer dependency. --- lib/mastodon/router.js | 16 +--- lib/mastodon/routes/media.js | 37 +++----- package-lock.json | 168 +---------------------------------- package.json | 3 +- 4 files changed, 21 insertions(+), 203 deletions(-) diff --git a/lib/mastodon/router.js b/lib/mastodon/router.js index 9cb25ca..8ba5140 100644 --- a/lib/mastodon/router.js +++ b/lib/mastodon/router.js @@ -67,18 +67,10 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) { // ─── Body parsers ─────────────────────────────────────────────────────── // Mastodon clients send JSON, form-urlencoded, and occasionally text/plain. - // Skip multipart/form-data requests — multer handles those in media routes. - // If express.json/urlencoded consume the stream first, multer gets nothing. - const jsonParser = express.json(); - const urlencodedParser = express.urlencoded({ extended: true }); - router.use("/api", (req, res, next) => { - if (req.is("multipart/form-data")) return next(); - jsonParser(req, res, next); - }); - router.use("/api", (req, res, next) => { - if (req.is("multipart/form-data")) return next(); - urlencodedParser(req, res, next); - }); + // Note: multipart/form-data is handled globally by express-fileupload + // (configured in Indiekit's express.js), so no multer needed here. + router.use("/api", express.json()); + router.use("/api", express.urlencoded({ extended: true })); router.use("/oauth", express.json()); router.use("/oauth", express.urlencoded({ extended: true })); diff --git a/lib/mastodon/routes/media.js b/lib/mastodon/routes/media.js index 8ade77e..b948a1d 100644 --- a/lib/mastodon/routes/media.js +++ b/lib/mastodon/routes/media.js @@ -5,18 +5,16 @@ * 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) + * + * File uploads are handled by express-fileupload (configured globally by + * Indiekit's express.js). Files arrive on req.files, NOT req.file (multer). */ 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. @@ -54,6 +52,7 @@ function serializeMediaAttachment(doc) { /** * Upload file to the Micropub media endpoint. + * Accepts an express-fileupload file object (has .data Buffer, .mimetype, .name). * Returns the URL from the Location header. */ async function uploadToMediaEndpoint(file, application, token) { @@ -67,8 +66,8 @@ async function uploadToMediaEndpoint(file, application, token) { : 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 blob = new Blob([file.data], { type: file.mimetype }); + formData.append("file", blob, file.name); const response = await fetch(mediaUrl, { method: "POST", @@ -95,7 +94,6 @@ router.post( "/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), - upload.single("file"), async (req, res, next) => { try { const { application } = req.app.locals; @@ -103,7 +101,8 @@ router.post( const token = req.session?.access_token || req.mastodonToken?.accessToken; - if (!req.file) { + const file = req.files?.file; + if (!file) { return res.status(422).json({ error: "No file provided" }); } @@ -113,17 +112,13 @@ router.post( .json({ error: "Authentication required for media upload" }); } - const fileUrl = await uploadToMediaEndpoint( - req.file, - application, - token, - ); + const fileUrl = await uploadToMediaEndpoint(file, application, token); const doc = { url: fileUrl, description: req.body.description || "", focus: req.body.focus || null, - mimeType: req.file.mimetype, + mimeType: file.mimetype, createdAt: new Date(), }; @@ -143,7 +138,6 @@ router.post( "/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), - upload.single("file"), async (req, res, next) => { try { const { application } = req.app.locals; @@ -151,7 +145,8 @@ router.post( const token = req.session?.access_token || req.mastodonToken?.accessToken; - if (!req.file) { + const file = req.files?.file; + if (!file) { return res.status(422).json({ error: "No file provided" }); } @@ -161,17 +156,13 @@ router.post( .json({ error: "Authentication required for media upload" }); } - const fileUrl = await uploadToMediaEndpoint( - req.file, - application, - token, - ); + const fileUrl = await uploadToMediaEndpoint(file, application, token); const doc = { url: fileUrl, description: req.body.description || "", focus: req.body.focus || null, - mimeType: req.file.mimetype, + mimeType: file.mimetype, createdAt: new Date(), }; diff --git a/package-lock.json b/package-lock.json index b2262e5..c0b9672 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.2", + "version": "3.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.2", + "version": "3.11.3", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", @@ -17,7 +17,6 @@ "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" }, @@ -1518,12 +1517,6 @@ "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", @@ -1586,23 +1579,6 @@ "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", @@ -1751,21 +1727,6 @@ "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", @@ -3014,68 +2975,6 @@ "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", @@ -3402,20 +3301,6 @@ "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", @@ -3453,26 +3338,6 @@ "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", @@ -3714,23 +3579,6 @@ "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", @@ -3782,12 +3630,6 @@ "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", @@ -3878,12 +3720,6 @@ "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 95fd27f..c0d91b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.2", + "version": "3.11.3", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", @@ -45,7 +45,6 @@ "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" }, From 33ae574b3e9ccd18358d900f6bcd833242c104e9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 17:15:50 +0200 Subject: [PATCH 10/37] fix: bridge IndieAuth token into Mastodon OAuth for media uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store the IndieAuth session token (req.session.access_token) on the OAuth authorization code document during POST /oauth/authorize. This token persists through the code→token exchange and is available as req.mastodonToken.indieauthToken when Mastodon clients make API calls. Media upload routes now use this bridged token to call the Micropub media endpoint, which requires IndieAuth authentication. --- lib/mastodon/routes/media.js | 14 ++++++++++++-- lib/mastodon/routes/oauth.js | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/mastodon/routes/media.js b/lib/mastodon/routes/media.js index b948a1d..d7f1e7f 100644 --- a/lib/mastodon/routes/media.js +++ b/lib/mastodon/routes/media.js @@ -98,8 +98,13 @@ router.post( try { const { application } = req.app.locals; const collections = req.app.locals.mastodonCollections; + // Use IndieAuth token stored during OAuth authorization, falling back + // to session token (native reader) or Mastodon token (won't work for + // Micropub media endpoint but covers direct internal calls). const token = - req.session?.access_token || req.mastodonToken?.accessToken; + req.session?.access_token || + req.mastodonToken?.indieauthToken || + req.mastodonToken?.accessToken; const file = req.files?.file; if (!file) { @@ -142,8 +147,13 @@ router.post( try { const { application } = req.app.locals; const collections = req.app.locals.mastodonCollections; + // Use IndieAuth token stored during OAuth authorization, falling back + // to session token (native reader) or Mastodon token (won't work for + // Micropub media endpoint but covers direct internal calls). const token = - req.session?.access_token || req.mastodonToken?.accessToken; + req.session?.access_token || + req.mastodonToken?.indieauthToken || + req.mastodonToken?.accessToken; const file = req.files?.file; if (!file) { diff --git a/lib/mastodon/routes/oauth.js b/lib/mastodon/routes/oauth.js index 20cd096..1748ee8 100644 --- a/lib/mastodon/routes/oauth.js +++ b/lib/mastodon/routes/oauth.js @@ -388,6 +388,9 @@ router.post("/oauth/authorize", async (req, res, next) => { redirectUri: redirect_uri, codeChallenge: code_challenge || null, codeChallengeMethod: code_challenge_method || null, + // Store the IndieAuth session token so Mastodon API routes can call + // Micropub/media endpoints on behalf of this user (single-user server). + indieauthToken: req.session?.access_token || null, createdAt: new Date(), expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes }); diff --git a/package-lock.json b/package-lock.json index c0b9672..a5b256e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.3", + "version": "3.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.3", + "version": "3.11.4", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", diff --git a/package.json b/package.json index c0d91b5..13752d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.3", + "version": "3.11.4", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From d194629f7d1c9544f80a5476a6e36c8ddee39c62 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 17:39:38 +0200 Subject: [PATCH 11/37] fix: use url not value in JF2 photo objects for media_ids --- lib/mastodon/routes/statuses.js | 9 +++++++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 52f0601..e572b8c 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -18,6 +18,7 @@ import express from "express"; import { ObjectId } from "mongodb"; import { serializeStatus } from "../entities/status.js"; import { decodeCursor } from "../helpers/pagination.js"; +import { resolveReplyIds } from "../helpers/resolve-reply-ids.js"; import { likePost, unlikePost, boostPost, unboostPost, @@ -43,11 +44,13 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st // Load interaction state if authenticated const interactionState = await loadItemInteractions(collections, item); + const replyIdMap = await resolveReplyIds(collections.ap_timeline, [item]); const status = serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set(), + replyIdMap, }); res.json(status); @@ -122,7 +125,9 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", pinnedIds: new Set(), }; - const serializeOpts = { baseUrl, ...emptyInteractions }; + const allItems = [...ancestors, ...descendants]; + const replyIdMap = await resolveReplyIds(collections.ap_timeline, allItems); + const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap }; res.json({ ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), @@ -245,7 +250,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta for (const m of mediaUrls) { if (m.type?.startsWith("image/")) { if (!jf2.photo) jf2.photo = []; - jf2.photo.push({ value: m.url, alt: m.alt }); + jf2.photo.push({ url: m.url, alt: m.alt }); } else if (m.type?.startsWith("video/")) { if (!jf2.video) jf2.video = []; jf2.video.push(m.url); diff --git a/package-lock.json b/package-lock.json index a5b256e..c003eb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.4", + "version": "3.11.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.4", + "version": "3.11.5", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", diff --git a/package.json b/package.json index 13752d0..b920a7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.4", + "version": "3.11.5", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 23df10ad87c712a9a08bf37fe554abfca6d19a08 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 17:41:01 +0200 Subject: [PATCH 12/37] fix: resolve in_reply_to_id for threading + append permalink to timeline content Two fixes for Mastodon API parity: 1. Implement in_reply_to_id resolution (was hardcoded null TODO). New resolve-reply-ids.js batch-looks up parent posts in ap_timeline and returns cursor IDs. Wired into status, context, and timeline route handlers via replyIdMap option on serializeStatus. 2. Append permalink to own posts in ap_timeline content, matching the AS2 federation output. Without this, posts viewed via Mastodon API (Phanpy/Moshidon) lacked the source link that fediverse users see. --- lib/mastodon/entities/status.js | 6 ++-- lib/mastodon/helpers/resolve-reply-ids.js | 44 +++++++++++++++++++++++ lib/mastodon/routes/timelines.js | 11 ++++++ lib/syndicator.js | 13 ++++++- package.json | 2 +- 5 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 lib/mastodon/helpers/resolve-reply-ids.js diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index db476ce..871e3af 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) { * @param {Set} [options.pinnedIds] - UIDs the user has pinned * @returns {object} Mastodon Status entity */ -export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) { +export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) { if (!item) return null; // Use published-based cursor as the status ID so pagination cursors @@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo return { id, created_at: published, - in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID - in_reply_to_account_id: null, // TODO: resolve + in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null, + in_reply_to_account_id: null, sensitive, spoiler_text: spoilerText, visibility, diff --git a/lib/mastodon/helpers/resolve-reply-ids.js b/lib/mastodon/helpers/resolve-reply-ids.js new file mode 100644 index 0000000..2295ded --- /dev/null +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -0,0 +1,44 @@ +/** + * Batch-resolve inReplyTo URLs to Mastodon cursor IDs. + * + * Looks up parent posts in ap_timeline by uid/url and returns a Map + * of inReplyTo URL → cursor ID (milliseconds since epoch as string). + * Used by route handlers before calling serializeStatus(). + * + * @param {object} collection - ap_timeline MongoDB collection + * @param {Array} items - Timeline items with optional inReplyTo + * @returns {Promise>} Map of URL → cursor ID + */ +import { encodeCursor } from "./pagination.js"; + +export async function resolveReplyIds(collection, items) { + const map = new Map(); + if (!collection || !items?.length) return map; + + // Collect unique inReplyTo URLs + const urls = [ + ...new Set( + items + .map((item) => item.inReplyTo) + .filter(Boolean), + ), + ]; + if (urls.length === 0) return map; + + // Batch lookup parents by uid or url + const parents = await collection + .find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] }) + .project({ uid: 1, url: 1, published: 1 }) + .toArray(); + + for (const parent of parents) { + const cursorId = encodeCursor(parent.published); + if (cursorId && cursorId !== "0") { + // Map both uid and url to the cursor ID + if (parent.uid) map.set(parent.uid, cursorId); + if (parent.url && parent.url !== parent.uid) map.set(parent.url, cursorId); + } + } + + return map; +} diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 991565e..66924cd 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -8,6 +8,7 @@ import express from "express"; import { serializeStatus } from "../entities/status.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; +import { resolveReplyIds } from "../helpers/resolve-reply-ids.js"; import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; import { enrichAccountStats } from "../helpers/enrich-accounts.js"; import { tokenRequired } from "../middleware/token-required.js"; @@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: items, ); + // Resolve reply parent IDs for threading + const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + // Serialize to Mastodon Status entities const statuses = items.map((item) => serializeStatus(item, { @@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap, }), ); @@ -163,6 +168,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { )); } + const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + const statuses = items.map((item) => serializeStatus(item, { baseUrl, @@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap, }), ); @@ -226,6 +234,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { )); } + const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + const statuses = items.map((item) => serializeStatus(item, { baseUrl, @@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap, }), ); diff --git a/lib/syndicator.js b/lib/syndicator.js index 55b918b..b7f9320 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -344,8 +344,19 @@ function buildTimelineContent(properties) { }; } - // Regular post — return body content as-is + // Regular post — append permalink to match federated AS2 content. + // Without this, the Mastodon API timeline entry lacks the link back + // to the source post that fediverse users see via federation. if (bodyText || bodyHtml) { + const postUrl = properties.url; + if (postUrl) { + const linkText = `\n\n\u{1F517} ${postUrl}`; + const linkHtml = `

\u{1F517} ${esc(postUrl)}

`; + return { + text: `${bodyText}${linkText}`, + html: `${bodyHtml}\n${linkHtml}`, + }; + } return { text: bodyText, html: bodyHtml }; } diff --git a/package.json b/package.json index b920a7c..35ec73f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.5", + "version": "3.11.6", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 232d942e3f907d91cd2a5e832ce6c0ac36f4c460 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 20:09:55 +0200 Subject: [PATCH 13/37] fix: resolve in_reply_to_account_id for Phanpy reply threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phanpy uses in_reply_to_account_id to show "Replying to @user" context. Without it, replies appear as standalone posts in the timeline. resolveReplyIds now returns both replyIdMap and replyAccountIdMap. Account IDs computed via remoteActorId(author.url) — same deterministic hash used by the account entity serializer. --- lib/mastodon/entities/status.js | 4 +-- lib/mastodon/helpers/resolve-reply-ids.js | 37 ++++++++++++++--------- lib/mastodon/routes/statuses.js | 7 +++-- lib/mastodon/routes/timelines.js | 3 +- package.json | 2 +- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index 871e3af..c0c53e1 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) { * @param {Set} [options.pinnedIds] - UIDs the user has pinned * @returns {object} Mastodon Status entity */ -export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) { +export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) { if (!item) return null; // Use published-based cursor as the status ID so pagination cursors @@ -205,7 +205,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo id, created_at: published, in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null, - in_reply_to_account_id: null, + in_reply_to_account_id: replyAccountIdMap?.get(item.inReplyTo) ?? null, sensitive, spoiler_text: spoilerText, visibility, diff --git a/lib/mastodon/helpers/resolve-reply-ids.js b/lib/mastodon/helpers/resolve-reply-ids.js index 2295ded..d8d8c6c 100644 --- a/lib/mastodon/helpers/resolve-reply-ids.js +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -1,19 +1,23 @@ /** - * Batch-resolve inReplyTo URLs to Mastodon cursor IDs. + * Batch-resolve inReplyTo URLs to Mastodon cursor IDs and account IDs. + * + * Looks up parent posts in ap_timeline by uid/url and returns two Maps: + * - replyIdMap: inReplyTo URL → cursor ID (status ID) + * - replyAccountIdMap: inReplyTo URL → author account ID * - * Looks up parent posts in ap_timeline by uid/url and returns a Map - * of inReplyTo URL → cursor ID (milliseconds since epoch as string). * Used by route handlers before calling serializeStatus(). * * @param {object} collection - ap_timeline MongoDB collection * @param {Array} items - Timeline items with optional inReplyTo - * @returns {Promise>} Map of URL → cursor ID + * @returns {Promise<{replyIdMap: Map, replyAccountIdMap: Map}>} */ import { encodeCursor } from "./pagination.js"; +import { remoteActorId } from "./id-mapping.js"; export async function resolveReplyIds(collection, items) { - const map = new Map(); - if (!collection || !items?.length) return map; + const replyIdMap = new Map(); + const replyAccountIdMap = new Map(); + if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap }; // Collect unique inReplyTo URLs const urls = [ @@ -23,22 +27,27 @@ export async function resolveReplyIds(collection, items) { .filter(Boolean), ), ]; - if (urls.length === 0) return map; + if (urls.length === 0) return { replyIdMap, replyAccountIdMap }; // Batch lookup parents by uid or url const parents = await collection .find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] }) - .project({ uid: 1, url: 1, published: 1 }) + .project({ uid: 1, url: 1, published: 1, "author.url": 1 }) .toArray(); for (const parent of parents) { const cursorId = encodeCursor(parent.published); - if (cursorId && cursorId !== "0") { - // Map both uid and url to the cursor ID - if (parent.uid) map.set(parent.uid, cursorId); - if (parent.url && parent.url !== parent.uid) map.set(parent.url, cursorId); - } + const authorUrl = parent.author?.url; + const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null; + + const setMaps = (key) => { + if (cursorId && cursorId !== "0") replyIdMap.set(key, cursorId); + if (authorAccountId) replyAccountIdMap.set(key, authorAccountId); + }; + + if (parent.uid) setMaps(parent.uid); + if (parent.url && parent.url !== parent.uid) setMaps(parent.url); } - return map; + return { replyIdMap, replyAccountIdMap }; } diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index e572b8c..d275b8e 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -44,13 +44,14 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st // Load interaction state if authenticated const interactionState = await loadItemInteractions(collections, item); - const replyIdMap = await resolveReplyIds(collections.ap_timeline, [item]); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [item]); const status = serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set(), replyIdMap, + replyAccountIdMap, }); res.json(status); @@ -126,8 +127,8 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", }; const allItems = [...ancestors, ...descendants]; - const replyIdMap = await resolveReplyIds(collections.ap_timeline, allItems); - const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap }; + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); + const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap }; res.json({ ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 66924cd..dc88015 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -65,7 +65,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: ); // Resolve reply parent IDs for threading - const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, items); // Serialize to Mastodon Status entities const statuses = items.map((item) => @@ -76,6 +76,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: bookmarkedIds, pinnedIds: new Set(), replyIdMap, + replyAccountIdMap, }), ); diff --git a/package.json b/package.json index 35ec73f..90313eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.6", + "version": "3.11.7", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From f8cb42b10e503b1b29c9369dbb7b379eb2311ffc Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 21:04:45 +0200 Subject: [PATCH 14/37] fix: destructure resolveReplyIds return in public/hashtag timeline handlers --- lib/mastodon/routes/timelines.js | 10 ++++++---- package.json | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index dc88015..54a6124 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -169,7 +169,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { )); } - const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items); const statuses = items.map((item) => serializeStatus(item, { @@ -178,7 +178,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), - replyIdMap, + replyIdMap: rIdMap, + replyAccountIdMap: rAcctMap, }), ); @@ -235,7 +236,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { )); } - const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items); const statuses = items.map((item) => serializeStatus(item, { @@ -244,7 +245,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), - replyIdMap, + replyIdMap: rIdMap, + replyAccountIdMap: rAcctMap, }), ); diff --git a/package.json b/package.json index 90313eb..47fd0ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.7", + "version": "3.11.8", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 6e63422c213f407c92d3988a91d01a7a73dc8bac Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 09:57:37 +0200 Subject: [PATCH 15/37] feat!: replace cursor-based status IDs with MongoDB ObjectId BREAKING: Status IDs are now _id.toString() instead of encodeCursor(published). This fixes the critical threading bug where multiple posts sharing the same published second produced identical IDs, causing findTimelineItemById to return the wrong document. Changes: - status.js: id = _id.toString() (unique, chronologically sortable) - notification.js: same - findTimelineItemById: ObjectId-only lookup (no cursor fallback) - pagination.js: _id-based cursor pagination ($lt/$gt on ObjectId) - resolve-reply-ids.js: returns _id.toString() for parent IDs - Removed all encodeCursor/decodeCursor usage from API layer ObjectIds have a 4-byte timestamp prefix so chronological sort via _id: -1 works correctly. Pagination cursors are now ObjectId hex strings in Link headers. --- lib/mastodon/entities/notification.js | 3 +- lib/mastodon/entities/status.js | 7 +- lib/mastodon/helpers/pagination.js | 117 +++++++++------------- lib/mastodon/helpers/resolve-reply-ids.js | 21 ++-- lib/mastodon/routes/statuses.js | 24 +---- package.json | 2 +- 6 files changed, 61 insertions(+), 113 deletions(-) diff --git a/lib/mastodon/entities/notification.js b/lib/mastodon/entities/notification.js index 9ee0093..7086bee 100644 --- a/lib/mastodon/entities/notification.js +++ b/lib/mastodon/entities/notification.js @@ -13,7 +13,6 @@ */ import { serializeAccount } from "./account.js"; import { serializeStatus } from "./status.js"; -import { encodeCursor } from "../helpers/pagination.js"; /** * Map internal notification types to Mastodon API types. @@ -121,7 +120,7 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt : notif.published || notif.createdAt || new Date().toISOString(); return { - id: encodeCursor(createdAt) || notif._id.toString(), + id: notif._id.toString(), type: mastodonType, created_at: createdAt, account, diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index c0c53e1..bcd5652 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -15,7 +15,6 @@ */ import { serializeAccount } from "./account.js"; import { sanitizeHtml } from "./sanitize.js"; -import { encodeCursor } from "../helpers/pagination.js"; // Module-level defaults set once at startup via setLocalIdentity() let _localPublicationUrl = ""; @@ -47,10 +46,8 @@ export function setLocalIdentity(publicationUrl, handle) { export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) { if (!item) return null; - // Use published-based cursor as the status ID so pagination cursors - // (max_id/min_id) sort chronologically, not by insertion order. - const cursorDate = item.published || item.createdAt || item.boostedAt; - const id = encodeCursor(cursorDate) || item._id.toString(); + // Use MongoDB ObjectId as the status ID — unique and chronologically sortable. + const id = item._id.toString(); const uid = item.uid || ""; const url = item.url || uid; diff --git a/lib/mastodon/helpers/pagination.js b/lib/mastodon/helpers/pagination.js index 3f4da71..792b3ce 100644 --- a/lib/mastodon/helpers/pagination.js +++ b/lib/mastodon/helpers/pagination.js @@ -1,50 +1,17 @@ /** - * Mastodon-compatible cursor pagination helpers. + * Mastodon-compatible pagination helpers using MongoDB ObjectId. * - * Uses `published` date as cursor (chronologically correct) instead of - * MongoDB ObjectId. ObjectId reflects insertion order, not publication - * order — backfilled or syndicated posts get new ObjectIds at import - * time, breaking chronological sort. The `published` field matches the - * native reader's sort and produces a correct timeline. + * ObjectIds are 12-byte values with a 4-byte timestamp prefix, making + * them chronologically sortable. Status IDs are _id.toString() — unique, + * sortable, and directly usable as pagination cursors. * - * Cursor values are `published` ISO strings, but Mastodon clients pass - * them as opaque `max_id`/`min_id`/`since_id` strings. We encode the - * published date as a Mastodon-style snowflake-ish ID (milliseconds - * since epoch) so clients treat them as comparable integers. - * - * Emits RFC 8288 Link headers that masto.js / Phanpy parse. + * Emits RFC 8288 Link headers that Phanpy/Elk/Moshidon parse. */ +import { ObjectId } from "mongodb"; const DEFAULT_LIMIT = 20; const MAX_LIMIT = 40; -/** - * Encode a published date string as a numeric cursor ID. - * Mastodon clients expect IDs to be numeric strings that sort chronologically. - * We use milliseconds since epoch — monotonic and comparable. - * - * @param {string|Date} published - ISO date string or Date object - * @returns {string} Numeric string (ms since epoch) - */ -export function encodeCursor(published) { - if (!published) return "0"; - const ms = new Date(published).getTime(); - return Number.isFinite(ms) ? String(ms) : "0"; -} - -/** - * Decode a numeric cursor ID back to an ISO date string. - * - * @param {string} cursor - Numeric cursor from client - * @returns {string|null} ISO date string, or null if invalid - */ -export function decodeCursor(cursor) { - if (!cursor) return null; - const ms = Number.parseInt(cursor, 10); - if (!Number.isFinite(ms) || ms <= 0) return null; - return new Date(ms).toISOString(); -} - /** * Parse and clamp the limit parameter. * @@ -58,46 +25,60 @@ export function parseLimit(raw) { } /** - * Build a MongoDB filter object for cursor-based pagination. + * Try to parse a cursor string as an ObjectId. + * Returns null if invalid. * - * Mastodon cursor params (all optional, applied to `published`): - * max_id — return items older than this cursor (exclusive) - * min_id — return items newer than this cursor (exclusive), closest first - * since_id — return items newer than this cursor (exclusive), most recent first + * @param {string} cursor - ObjectId hex string from client + * @returns {ObjectId|null} + */ +function parseCursor(cursor) { + if (!cursor || typeof cursor !== "string") return null; + try { + return new ObjectId(cursor); + } catch { + return null; + } +} + +/** + * Build a MongoDB filter object for ObjectId-based pagination. + * + * Mastodon cursor params (all optional, applied to `_id`): + * max_id — return items older than this ID (exclusive) + * min_id — return items newer than this ID (exclusive), closest first + * since_id — return items newer than this ID (exclusive), most recent first * * @param {object} baseFilter - Existing MongoDB filter to extend * @param {object} cursors - * @param {string} [cursors.max_id] - Numeric cursor (ms since epoch) - * @param {string} [cursors.min_id] - Numeric cursor (ms since epoch) - * @param {string} [cursors.since_id] - Numeric cursor (ms since epoch) + * @param {string} [cursors.max_id] - ObjectId hex string + * @param {string} [cursors.min_id] - ObjectId hex string + * @param {string} [cursors.since_id] - ObjectId hex string * @returns {{ filter: object, sort: object, reverse: boolean }} */ export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) { const filter = { ...baseFilter }; - let sort = { published: -1 }; // newest first (default) + let sort = { _id: -1 }; // newest first (default) let reverse = false; if (max_id) { - const date = decodeCursor(max_id); - if (date) { - filter.published = { ...filter.published, $lt: date }; + const oid = parseCursor(max_id); + if (oid) { + filter._id = { ...filter._id, $lt: oid }; } } if (since_id) { - const date = decodeCursor(since_id); - if (date) { - filter.published = { ...filter.published, $gt: date }; + const oid = parseCursor(since_id); + if (oid) { + filter._id = { ...filter._id, $gt: oid }; } } if (min_id) { - const date = decodeCursor(min_id); - if (date) { - filter.published = { ...filter.published, $gt: date }; - // min_id returns results closest to the cursor, so sort ascending - // then reverse the results before returning - sort = { published: 1 }; + const oid = parseCursor(min_id); + if (oid) { + filter._id = { ...filter._id, $gt: oid }; + sort = { _id: 1 }; reverse = true; } } @@ -110,7 +91,7 @@ export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = * * @param {object} res - Express response object * @param {object} req - Express request object (for building URLs) - * @param {Array} items - Result items (must have `published`) + * @param {Array} items - Result items (must have `_id`) * @param {number} limit - The limit used for the query */ export function setPaginationHeaders(res, req, items, limit) { @@ -119,10 +100,8 @@ export function setPaginationHeaders(res, req, items, limit) { // Only emit Link if we got a full page (may have more) if (items.length < limit) return; - const firstCursor = encodeCursor(items[0].published); - const lastCursor = encodeCursor(items[items.length - 1].published); - - if (firstCursor === "0" || lastCursor === "0") return; + const firstId = items[0]._id.toString(); + const lastId = items[items.length - 1]._id.toString(); const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`; @@ -139,14 +118,14 @@ export function setPaginationHeaders(res, req, items, limit) { const links = []; - // rel="next" — older items (max_id = last item's cursor) + // rel="next" — older items (max_id = last item's ID) const nextParams = new URLSearchParams(existingParams); - nextParams.set("max_id", lastCursor); + nextParams.set("max_id", lastId); links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`); - // rel="prev" — newer items (min_id = first item's cursor) + // rel="prev" — newer items (min_id = first item's ID) const prevParams = new URLSearchParams(existingParams); - prevParams.set("min_id", firstCursor); + prevParams.set("min_id", firstId); links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`); res.set("Link", links.join(", ")); diff --git a/lib/mastodon/helpers/resolve-reply-ids.js b/lib/mastodon/helpers/resolve-reply-ids.js index d8d8c6c..cf57e1b 100644 --- a/lib/mastodon/helpers/resolve-reply-ids.js +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -1,17 +1,14 @@ /** - * Batch-resolve inReplyTo URLs to Mastodon cursor IDs and account IDs. + * Batch-resolve inReplyTo URLs to ObjectId strings and account IDs. * * Looks up parent posts in ap_timeline by uid/url and returns two Maps: - * - replyIdMap: inReplyTo URL → cursor ID (status ID) - * - replyAccountIdMap: inReplyTo URL → author account ID - * - * Used by route handlers before calling serializeStatus(). + * - replyIdMap: inReplyTo URL → parent _id.toString() + * - replyAccountIdMap: inReplyTo URL → parent author account ID * * @param {object} collection - ap_timeline MongoDB collection * @param {Array} items - Timeline items with optional inReplyTo * @returns {Promise<{replyIdMap: Map, replyAccountIdMap: Map}>} */ -import { encodeCursor } from "./pagination.js"; import { remoteActorId } from "./id-mapping.js"; export async function resolveReplyIds(collection, items) { @@ -19,29 +16,25 @@ export async function resolveReplyIds(collection, items) { const replyAccountIdMap = new Map(); if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap }; - // Collect unique inReplyTo URLs const urls = [ ...new Set( - items - .map((item) => item.inReplyTo) - .filter(Boolean), + items.map((item) => item.inReplyTo).filter(Boolean), ), ]; if (urls.length === 0) return { replyIdMap, replyAccountIdMap }; - // Batch lookup parents by uid or url const parents = await collection .find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] }) - .project({ uid: 1, url: 1, published: 1, "author.url": 1 }) + .project({ uid: 1, url: 1, "author.url": 1 }) .toArray(); for (const parent of parents) { - const cursorId = encodeCursor(parent.published); + const parentId = parent._id.toString(); const authorUrl = parent.author?.url; const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null; const setMaps = (key) => { - if (cursorId && cursorId !== "0") replyIdMap.set(key, cursorId); + replyIdMap.set(key, parentId); if (authorAccountId) replyAccountIdMap.set(key, authorAccountId); }; diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index d275b8e..fe7fd71 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -17,7 +17,6 @@ import express from "express"; import { ObjectId } from "mongodb"; import { serializeStatus } from "../entities/status.js"; -import { decodeCursor } from "../helpers/pagination.js"; import { resolveReplyIds } from "../helpers/resolve-reply-ids.js"; import { likePost, unlikePost, @@ -844,32 +843,13 @@ router.get("/api/v1/statuses/:id/card", async (req, res, next) => { // ─── Helpers ───────────────────────────────────────────────────────────────── /** - * Find a timeline item by cursor ID (published-based) or ObjectId (legacy). - * Status IDs are now encodeCursor(published) — milliseconds since epoch. - * Falls back to ObjectId lookup for backwards compatibility. + * Find a timeline item by ObjectId. * * @param {object} collection - ap_timeline collection - * @param {string} id - Status ID from client + * @param {string} id - MongoDB ObjectId string * @returns {Promise} Timeline document or null */ async function findTimelineItemById(collection, id) { - // Try cursor-based lookup first (published date from ms-since-epoch) - const publishedDate = decodeCursor(id); - if (publishedDate) { - // Try exact match first (with .000Z suffix from toISOString) - let item = await collection.findOne({ published: publishedDate }); - if (item) return item; - - // Try without milliseconds — stored dates often lack .000Z - // e.g., "2026-03-21T15:33:50Z" vs "2026-03-21T15:33:50.000Z" - const withoutMs = publishedDate.replace(/\.000Z$/, "Z"); - if (withoutMs !== publishedDate) { - item = await collection.findOne({ published: withoutMs }); - if (item) return item; - } - } - - // Fall back to ObjectId lookup (legacy IDs) try { return await collection.findOne({ _id: new ObjectId(id) }); } catch { diff --git a/package.json b/package.json index 47fd0ef..057750d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.8", + "version": "3.12.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From c3e6c5e66a47be20d357f164b8b4b9ec70dfd938 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 11:32:34 +0200 Subject: [PATCH 16/37] fix: append permalink to all post types in ap_timeline The permalink was only appended for regular posts (notes/articles) inside buildTimelineContent. Interaction types (likes, bookmarks, reposts) returned early before the permalink code ran. Moved permalink appending to the syndicator caller so it applies universally. --- lib/syndicator.js | 23 +++++++++++------------ package.json | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/syndicator.js b/lib/syndicator.js index b7f9320..9946927 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -225,6 +225,15 @@ export function createSyndicator(plugin) { try { const profile = await plugin._collections.ap_profile?.findOne({}); const content = buildTimelineContent(properties); + + // Append permalink to ALL post types so the Mastodon API timeline + // matches what fediverse users see via federation (jf2-to-as2 appends it too). + if (properties.url) { + const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + content.text = `${content.text}\n\n\u{1F517} ${properties.url}`; + content.html = `${content.html}\n

\u{1F517} ${esc(properties.url)}

`; + } + const timelineItem = { uid: properties.url, url: properties.url, @@ -344,19 +353,9 @@ function buildTimelineContent(properties) { }; } - // Regular post — append permalink to match federated AS2 content. - // Without this, the Mastodon API timeline entry lacks the link back - // to the source post that fediverse users see via federation. + // Regular post — return body content as-is. + // Permalink is appended by the caller (syndicator) for ALL post types. if (bodyText || bodyHtml) { - const postUrl = properties.url; - if (postUrl) { - const linkText = `\n\n\u{1F517} ${postUrl}`; - const linkHtml = `

\u{1F517} ${esc(postUrl)}

`; - return { - text: `${bodyText}${linkText}`, - html: `${bodyHtml}\n${linkHtml}`, - }; - } return { text: bodyText, html: bodyHtml }; } diff --git a/package.json b/package.json index 057750d..527174d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.12.0", + "version": "3.12.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 933ae9848305f589ac8e7f8ee966b9ec76140629 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 11:58:19 +0200 Subject: [PATCH 17/37] fix: append permalink at read time in serializeStatus, not write time Permalinks added at write time to ap_timeline get wiped by startup backfill and timeline cleanup (which delete and re-create entries). Moving to read time in serializeStatus means the link is always present regardless of how or when the timeline entry was created. Detects own posts via _localPublicationUrl match on author.url. Skips if the content already contains the URL (no duplicates). --- lib/mastodon/entities/status.js | 12 +++++++++++- lib/syndicator.js | 9 +-------- package.json | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index bcd5652..6ce2909 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -99,7 +99,17 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo } // Regular status (note, article, question) - const content = item.content?.html || item.content?.text || ""; + let content = item.content?.html || item.content?.text || ""; + + // Append permalink for own posts at read time — matches what fediverse + // users see via federation (jf2-to-as2 appends the same link). + // Done here instead of at write time so it survives backfills and cleanups. + const isOwnPost = _localPublicationUrl && item.author?.url === _localPublicationUrl; + const postUrl = item.uid || item.url; + if (isOwnPost && postUrl && !content.includes(postUrl)) { + const escaped = postUrl.replace(/&/g, "&").replace(//g, ">"); + content += `\n

\u{1F517} ${escaped}

`; + } const spoilerText = item.summary || ""; const sensitive = item.sensitive || false; const visibility = item.visibility || "public"; diff --git a/lib/syndicator.js b/lib/syndicator.js index 9946927..6739ccb 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -225,14 +225,7 @@ export function createSyndicator(plugin) { try { const profile = await plugin._collections.ap_profile?.findOne({}); const content = buildTimelineContent(properties); - - // Append permalink to ALL post types so the Mastodon API timeline - // matches what fediverse users see via federation (jf2-to-as2 appends it too). - if (properties.url) { - const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - content.text = `${content.text}\n\n\u{1F517} ${properties.url}`; - content.html = `${content.html}\n

\u{1F517} ${esc(properties.url)}

`; - } + // Permalink is appended at read time by serializeStatus, not here. const timelineItem = { uid: properties.url, diff --git a/package.json b/package.json index 527174d..423515b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.12.1", + "version": "3.12.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 32bf01e7bb4bf175c5df167271c0d0cde9331719 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 12:07:40 +0200 Subject: [PATCH 18/37] fix: resolve and linkify @mentions in ap_timeline content When the syndicator adds own posts to ap_timeline, it now: 1. Linkifies @user@domain patterns using WebFinger-resolved profile URLs (matching the federated AS2 content from jf2-to-as2.js) 2. Stores resolved mentions with actorUrl for proper Mastodon API serialization (deterministic account IDs via remoteActorId) serializeStatus now parses mention handles into proper username/acct fields with real account IDs instead of placeholder "0". --- lib/mastodon/entities/status.js | 19 ++++++++++++------- lib/syndicator.js | 28 ++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index 6ce2909..c38fcdd 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -15,6 +15,7 @@ */ import { serializeAccount } from "./account.js"; import { sanitizeHtml } from "./sanitize.js"; +import { remoteActorId } from "../helpers/id-mapping.js"; // Module-level defaults set once at startup via setLocalIdentity() let _localPublicationUrl = ""; @@ -178,13 +179,17 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo url: `${baseUrl}/tags/${encodeURIComponent(tag)}`, })); - // Mentions - const mentions = (item.mentions || []).map((m) => ({ - id: "0", // We don't have stable IDs for mentioned accounts - username: m.name || "", - url: m.url || "", - acct: m.name || "", - })); + // Mentions — use actorUrl for deterministic ID, parse acct from handle + const mentions = (item.mentions || []).map((m) => { + const handle = (m.name || "").replace(/^@/, ""); + const parts = handle.split("@"); + return { + id: m.actorUrl ? remoteActorId(m.actorUrl) : "0", + username: parts[0] || handle, + url: m.url || m.actorUrl || "", + acct: handle, + }; + }); // Custom emojis const emojis = (item.emojis || []).map((e) => ({ diff --git a/lib/syndicator.js b/lib/syndicator.js index 6739ccb..7cf70b1 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -227,11 +227,39 @@ export function createSyndicator(plugin) { const content = buildTimelineContent(properties); // Permalink is appended at read time by serializeStatus, not here. + // Linkify @mentions in content using resolved WebFinger data. + // This ensures the ap_timeline HTML has proper links for + // mentions, matching what the federated AS2 activity contains. + if (resolvedMentions.length > 0 && content.html) { + const { default: jf2Mod } = await import("./jf2-to-as2.js"); + // Import linkifyMentions — it's not exported, so inline the logic + for (const { handle, profileUrl, actorUrl } of resolvedMentions) { + const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(?@${handle}`, + ); + } + } + + // Store resolved mentions for Mastodon API serialization + const timelineMentions = resolvedMentions + .filter(m => m.actorUrl) + .map(m => ({ + name: `@${m.handle}`, + url: m.profileUrl || m.actorUrl, + actorUrl: m.actorUrl, + })); + const timelineItem = { uid: properties.url, url: properties.url, type: mapPostType(properties["post-type"]), content, + mentions: timelineMentions, author: { name: profile?.name || handle, url: profile?.url || plugin._publicationUrl, diff --git a/package.json b/package.json index 423515b..cbacbd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.12.2", + "version": "3.12.3", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 5f023e4d2676bf0a14c5ee0c6b8a287f4f783fa3 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 14:53:29 +0200 Subject: [PATCH 19/37] fix: make access tokens non-expiring (matching Mastodon behavior) Access tokens were expiring after 1 hour, causing Phanpy/Elk/Moshidon to get 401 errors and become unusable. Mastodon itself creates non-expiring access tokens (valid until revoked). Removed expiresAt from all three grant types (authorization_code, client_credentials, refresh_token). Refresh tokens keep their 90-day expiry as a safety measure. Removed expires_in from JSON responses. --- lib/mastodon/routes/oauth.js | 12 ++++-------- package.json | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/mastodon/routes/oauth.js b/lib/mastodon/routes/oauth.js index 1748ee8..3965263 100644 --- a/lib/mastodon/routes/oauth.js +++ b/lib/mastodon/routes/oauth.js @@ -467,7 +467,6 @@ router.post("/oauth/token", async (req, res, next) => { accessToken, createdAt: new Date(), grantType: "client_credentials", - expiresAt: new Date(Date.now() + 3600 * 1000), }); return res.json({ @@ -475,7 +474,6 @@ router.post("/oauth/token", async (req, res, next) => { token_type: "Bearer", scope: "read", created_at: Math.floor(Date.now() / 1000), - expires_in: 3600, }); } @@ -510,9 +508,9 @@ router.post("/oauth/token", async (req, res, next) => { $set: { accessToken: newAccessToken, refreshToken: newRefreshToken, - expiresAt: new Date(Date.now() + 3600 * 1000), refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000), }, + $unset: { expiresAt: "" }, }, ); @@ -522,7 +520,6 @@ router.post("/oauth/token", async (req, res, next) => { scope: existing.scopes.join(" "), created_at: Math.floor(existing.createdAt.getTime() / 1000), refresh_token: newRefreshToken, - expires_in: 3600, }); } @@ -590,8 +587,9 @@ router.post("/oauth/token", async (req, res, next) => { } } - // Generate access token and refresh token with expiry. - const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour + // Generate access token and refresh token. + // Access tokens do not expire (matching Mastodon behavior — valid until revoked). + // Refresh tokens expire after 90 days as a safety measure. const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days const accessToken = randomHex(64); const refreshToken = randomHex(64); @@ -601,7 +599,6 @@ router.post("/oauth/token", async (req, res, next) => { $set: { accessToken, refreshToken, - expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL), refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL), }, }, @@ -613,7 +610,6 @@ router.post("/oauth/token", async (req, res, next) => { scope: grant.scopes.join(" "), created_at: Math.floor(grant.createdAt.getTime() / 1000), refresh_token: refreshToken, - expires_in: 3600, }); } catch (error) { next(error); diff --git a/package.json b/package.json index cbacbd1..6d7d1b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.12.3", + "version": "3.12.4", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 8d833bec89312edc2e4c8474535b7019ba7e483c Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 19:56:32 +0200 Subject: [PATCH 20/37] fix: filter replies from public/local/hashtag timelines (Mastodon behavior) --- lib/mastodon/routes/timelines.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 54a6124..01328ca 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -102,9 +102,10 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { const baseUrl = `${req.protocol}://${req.get("host")}`; const limit = parseLimit(req.query.limit); - // Public timeline: only public visibility, no context items + // Public timeline: only public visibility, no context items, no replies const baseFilter = { isContext: { $ne: true }, + inReplyTo: { $exists: false }, visibility: "public", }; @@ -204,6 +205,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { const baseFilter = { isContext: { $ne: true }, + inReplyTo: { $exists: false }, visibility: { $in: ["public", "unlisted"] }, category: hashtag, }; From dc05f9d304dc8e118369c8ac3c674330c94c3841 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 20:00:15 +0200 Subject: [PATCH 21/37] feat: apply keyword filters to timeline responses --- lib/mastodon/helpers/apply-filters.js | 158 ++++++++++++++++++++++++++ lib/mastodon/routes/timelines.js | 28 ++++- 2 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 lib/mastodon/helpers/apply-filters.js diff --git a/lib/mastodon/helpers/apply-filters.js b/lib/mastodon/helpers/apply-filters.js new file mode 100644 index 0000000..87dc135 --- /dev/null +++ b/lib/mastodon/helpers/apply-filters.js @@ -0,0 +1,158 @@ +/** + * Keyword filter helpers for Mastodon Client API v2. + * + * Loads active filters from MongoDB and applies them to serialized + * Mastodon Status objects, following the v2 filter spec: + * - filterAction "hide" → status removed from results + * - filterAction "warn" → status kept with `filtered` array attached + */ + +/** + * Strip HTML tags from a string for plain-text keyword matching. + * + * @param {string} html - HTML string + * @returns {string} Plain text + */ +function stripHtml(html) { + if (!html) return ""; + return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); +} + +/** + * Compile a regex from a list of keyword documents. + * + * Keywords with `wholeWord: true` are wrapped in `\b` word boundaries. + * Keywords with `wholeWord: false` are matched as plain substrings. + * Returns null if there are no keywords. + * + * @param {Array<{keyword: string, wholeWord: boolean}>} keywords + * @returns {RegExp|null} + */ +function compileKeywordRegex(keywords) { + if (!keywords || keywords.length === 0) return null; + + const parts = keywords.map((kw) => { + const escaped = kw.keyword.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"); + return kw.wholeWord ? `\\b${escaped}\\b` : escaped; + }); + + return new RegExp(parts.join("|"), "i"); +} + +/** + * Load active filters for a given context from MongoDB. + * + * Skips expired filters. For each filter, loads its keywords and compiles + * a single regex from all of them. + * + * @param {object} collections - MongoDB collections (must have ap_filters, ap_filter_keywords) + * @param {string} context - Filter context to match ("home", "public", "notifications", "thread") + * @returns {Promise>} + */ +export async function loadUserFilters(collections, context) { + if (!collections.ap_filters) return []; + + const now = new Date().toISOString(); + + // Load filters that include this context, skipping expired ones + const filterDocs = await collections.ap_filters + .find({ context }) + .toArray(); + + const activeFilters = filterDocs.filter((f) => { + if (!f.expiresAt) return true; + return f.expiresAt > now; + }); + + if (activeFilters.length === 0) return []; + + const result = []; + + for (const filter of activeFilters) { + const keywords = collections.ap_filter_keywords + ? await collections.ap_filter_keywords + .find({ filterId: filter._id }) + .toArray() + : []; + + const regex = compileKeywordRegex(keywords); + + result.push({ + id: filter._id.toString(), + title: filter.title || "", + context: filter.context || [], + filterAction: filter.filterAction || "warn", + expiresAt: filter.expiresAt || null, + regex, + keywords, + }); + } + + return result; +} + +/** + * Apply compiled filters to an array of serialized Mastodon statuses. + * + * - "hide" filters: matching statuses are removed entirely + * - "warn" filters: matching statuses get a `filtered` array attached + * + * @param {Array} statuses - Serialized Mastodon Status objects + * @param {Array} filters - Compiled filter objects from loadUserFilters() + * @returns {Array} Processed statuses (hide-matched ones removed) + */ +export function applyFilters(statuses, filters) { + if (!filters || filters.length === 0) return statuses; + + const result = []; + + for (const status of statuses) { + const text = stripHtml(status.content || ""); + let hidden = false; + + for (const filter of filters) { + if (!filter.regex) continue; + + const match = text.match(filter.regex); + if (!match) continue; + + if (filter.filterAction === "hide") { + hidden = true; + break; + } + + // filterAction === "warn" — attach filtered metadata + const matchedKeywords = filter.keywords + .filter((kw) => { + const escaped = kw.keyword.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"); + const kwRegex = new RegExp( + kw.wholeWord ? `\\b${escaped}\\b` : escaped, + "i", + ); + return kwRegex.test(text); + }) + .map((kw) => kw.keyword); + + if (!status.filtered) { + status.filtered = []; + } + + status.filtered.push({ + filter: { + id: filter.id, + title: filter.title, + context: filter.context, + filter_action: filter.filterAction, + expires_at: filter.expiresAt, + }, + keyword_matches: matchedKeywords, + }); + } + + if (!hidden) { + result.push(status); + } + } + + return result; +} diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 01328ca..c3696f7 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -11,6 +11,7 @@ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpe import { resolveReplyIds } from "../helpers/resolve-reply-ids.js"; import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; import { enrichAccountStats } from "../helpers/enrich-accounts.js"; +import { loadUserFilters, applyFilters } from "../helpers/apply-filters.js"; import { tokenRequired } from "../middleware/token-required.js"; import { scopeRequired } from "../middleware/scope-required.js"; @@ -85,10 +86,17 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: const pluginOptions = req.app.locals.mastodonPluginOptions || {}; await enrichAccountStats(statuses, pluginOptions, baseUrl); + // Apply keyword filters + let filteredStatuses = statuses; + if (collections.ap_filters) { + const filters = await loadUserFilters(collections, "home"); + filteredStatuses = applyFilters(statuses, filters); + } + // Set pagination Link headers setPaginationHeaders(res, req, items, limit); - res.json(statuses); + res.json(filteredStatuses); } catch (error) { next(error); } @@ -187,8 +195,15 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { const pluginOpts = req.app.locals.mastodonPluginOptions || {}; await enrichAccountStats(statuses, pluginOpts, baseUrl); + // Apply keyword filters + let filteredStatuses = statuses; + if (collections.ap_filters) { + const filters = await loadUserFilters(collections, "public"); + filteredStatuses = applyFilters(statuses, filters); + } + setPaginationHeaders(res, req, items, limit); - res.json(statuses); + res.json(filteredStatuses); } catch (error) { next(error); } @@ -255,8 +270,15 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { const pluginOpts = req.app.locals.mastodonPluginOptions || {}; await enrichAccountStats(statuses, pluginOpts, baseUrl); + // Apply keyword filters + let filteredStatuses = statuses; + if (collections.ap_filters) { + const filters = await loadUserFilters(collections, "public"); + filteredStatuses = applyFilters(statuses, filters); + } + setPaginationHeaders(res, req, items, limit); - res.json(statuses); + res.json(filteredStatuses); } catch (error) { next(error); } From 5ac14ee3d45bc9d3cb5d654d2d0ca84da2ccd766 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 20:01:46 +0200 Subject: [PATCH 22/37] docs: document Mastodon API improvements (ObjectId IDs, tokens, filters, reply filtering) --- CLAUDE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 26d2361..7f17a80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -432,6 +432,39 @@ When creating posts via `POST /api/v1/statuses`: **Previous behavior (pre-3.9.4):** The handler created an `ap_timeline` entry immediately and used `processStatusContent()` to linkify URLs with hardcoded `/@username` patterns. This caused: (1) posts appearing in timeline before syndication, (2) broken mention URLs for non-Mastodon servers, (3) links lost in the Micropub content file. +### 36. Mastodon API — Status IDs and Threading (v3.12.0+) + +**Status IDs are MongoDB ObjectId hex strings** (`_id.toString()`), NOT published-date cursors. This guarantees uniqueness — the previous cursor-based IDs (`encodeCursor(published)`) caused collisions when multiple posts shared the same second, resulting in `findTimelineItemById` returning wrong documents. + +**Key behaviors:** +- `findTimelineItemById` does ObjectId-only lookup — no date parsing, no ambiguity +- `in_reply_to_id` and `in_reply_to_account_id` are batch-resolved via `resolve-reply-ids.js` using parent's `_id.toString()` and `remoteActorId(author.url)` +- Pagination uses ObjectId ordering (`{ _id: -1 }`) — ObjectIds have a 4-byte timestamp prefix so chronological sort works +- `encodeCursor`/`decodeCursor` removed from the API layer entirely + +### 37. Mastodon API — Own Post Handling (v3.10.1+) + +Own posts are added to `ap_timeline` by the AP syndicator after successful delivery. The syndicator: +- Builds content from JF2 properties via `buildTimelineContent()` (synthesizes content for likes/bookmarks/reposts) +- Linkifies `@mentions` using WebFinger-resolved profile URLs +- Stores resolved mentions with `actorUrl` for proper serialization + +**Read-time enrichment by `serializeStatus`:** +- **Permalink** — appended for own posts (detected via `author.url === _localPublicationUrl`). Matches the `🔗` link in federated AS2 content. Done at read time so it survives timeline cleanup/backfill. +- **`@mention` links** — stored at write time on the `ap_timeline` entry with resolved `actorUrl` for deterministic Mastodon account IDs. + +### 38. Mastodon API — Access Tokens (v3.12.4+) + +**Access tokens do not expire.** They are valid until revoked, matching Mastodon's behavior. The previous 1-hour TTL caused Phanpy/Elk/Moshidon sessions to break silently. Refresh tokens expire after 90 days. + +### 39. Mastodon API — Timeline Filtering (v3.12.5+) + +**Reply filtering:** Public and hashtag timelines exclude replies (`inReplyTo: { $exists: false }`). Replies only appear in the context/thread view and the home timeline. This matches Mastodon/Pixelfed behavior. + +**Keyword filters:** The filters CRUD (`GET/POST/PUT/DELETE /api/v2/filters`) stores filters in `ap_filters` with keywords in `ap_filter_keywords`. `apply-filters.js` loads active filters per context, compiles keyword regexes, and applies them after status serialization: +- `filterAction: "hide"` — status removed from response +- `filterAction: "warn"` — status kept with `filtered` array attached (Mastodon v2 format) + ## Date Handling Convention **All dates MUST be stored as ISO 8601 strings.** This is mandatory across all Indiekit plugins. From 217b2dcb647eba574d883767738127710b5f6361 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 20:34:03 +0200 Subject: [PATCH 23/37] chore: bump to 3.12.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d7d1b6..ffe1d49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.12.4", + "version": "3.12.5", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 97fcccb9957b8538566a858ecf298f15d25b1b52 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:03:45 +0200 Subject: [PATCH 24/37] docs: document deferred home timeline reply visibility refinement --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7f17a80..77eb068 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -461,6 +461,8 @@ Own posts are added to `ap_timeline` by the AP syndicator after successful deliv **Reply filtering:** Public and hashtag timelines exclude replies (`inReplyTo: { $exists: false }`). Replies only appear in the context/thread view and the home timeline. This matches Mastodon/Pixelfed behavior. +**Home timeline reply visibility (DEFERRED):** Mastodon only shows replies in the home timeline when the user follows BOTH the replier AND the person being replied to. Our home timeline currently shows all replies from followed accounts regardless. Implementing this requires loading the following list and cross-checking each reply's target author — an expensive join per timeline load. Tracked as a future improvement. + **Keyword filters:** The filters CRUD (`GET/POST/PUT/DELETE /api/v2/filters`) stores filters in `ap_filters` with keywords in `ap_filter_keywords`. `apply-filters.js` loads active filters per context, compiles keyword regexes, and applies them after status serialization: - `filterAction: "hide"` — status removed from response - `filterAction: "warn"` — status kept with `filtered` array attached (Mastodon v2 format) From 2721249ca33bd28650a3bffd616ef08e764bd0f7 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:30:57 +0200 Subject: [PATCH 25/37] feat: add ap_settings collection and settings helper --- index.js | 3 +++ lib/settings.js | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 lib/settings.js diff --git a/index.js b/index.js index 15fa500..eec2615 100644 --- a/index.js +++ b/index.js @@ -969,6 +969,9 @@ export default class ActivityPubEndpoint { Indiekit.addCollection("ap_filters"); Indiekit.addCollection("ap_filter_keywords"); + // Plugin settings (single document, admin UI at /admin/settings) + Indiekit.addCollection("ap_settings"); + // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; this._collections = { diff --git a/lib/settings.js b/lib/settings.js new file mode 100644 index 0000000..cd10693 --- /dev/null +++ b/lib/settings.js @@ -0,0 +1,72 @@ +/** + * Plugin settings — stored in ap_settings MongoDB collection. + * + * getSettings() merges DB values over hardcoded defaults. + * Consumers call this once per operation (or use cached middleware for hot paths). + */ + +export const DEFAULTS = { + // Instance & Client API + instanceLanguages: ["en"], + maxCharacters: 5000, + maxMediaAttachments: 4, + defaultVisibility: "public", + defaultLanguage: "en", + + // Federation & Delivery + timelineRetention: 1000, + notificationRetentionDays: 30, + activityRetentionDays: 90, + replyChainDepth: 5, + broadcastBatchSize: 25, + broadcastBatchDelay: 5000, + parallelWorkers: 5, + logLevel: "warning", + + // Migration + refollowBatchSize: 10, + refollowDelay: 3000, + refollowBatchDelay: 30000, + + // Security + refreshTokenTtlDays: 90, +}; + +/** + * Load settings from MongoDB, merged over defaults. + * + * @param {Map|object} collections - Indiekit collections map or plain object with ap_settings + * @returns {Promise} Settings object with all keys guaranteed present + */ +export async function getSettings(collections) { + const col = collections?.get + ? collections.get("ap_settings") + : collections?.ap_settings; + if (!col) return { ...DEFAULTS }; + + try { + const doc = await col.findOne({}); + return { ...DEFAULTS, ...(doc?.settings || {}) }; + } catch { + return { ...DEFAULTS }; + } +} + +/** + * Save settings to MongoDB. + * + * @param {Map|object} collections - Indiekit collections map or plain object + * @param {object} settings - Settings object (all keys from DEFAULTS) + */ +export async function saveSettings(collections, settings) { + const col = collections?.get + ? collections.get("ap_settings") + : collections?.ap_settings; + if (!col) return; + + await col.updateOne( + {}, + { $set: { settings, updatedAt: new Date().toISOString() } }, + { upsert: true }, + ); +} From 01549a085ac5855c22fcee88bd59a37f7a7643a4 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:37:36 +0200 Subject: [PATCH 26/37] feat: add settings GET/POST controller --- lib/controllers/settings.js | 92 +++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 lib/controllers/settings.js diff --git a/lib/controllers/settings.js b/lib/controllers/settings.js new file mode 100644 index 0000000..3efcddd --- /dev/null +++ b/lib/controllers/settings.js @@ -0,0 +1,92 @@ +/** + * Settings controller — admin page for ActivityPub plugin configuration. + * + * GET: loads settings from ap_settings, renders form with defaults + * POST: validates, saves settings, redirects with success message + */ +import { getSettings, saveSettings, DEFAULTS } from "../settings.js"; +import { getToken, validateToken } from "../csrf.js"; + +export function settingsGetController(mountPath) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const settings = await getSettings(application.collections); + + response.render("activitypub-settings", { + title: response.locals.__("activitypub.settings.title"), + settings, + defaults: DEFAULTS, + mountPath, + saved: request.query.saved === "true", + csrfToken: getToken(request.session), + }); + } catch (error) { + next(error); + } + }; +} + +export function settingsPostController(mountPath) { + return async (request, response, next) => { + try { + if (!validateToken(request)) { + return response.status(403).render("error", { + title: "Error", + content: "Invalid CSRF token", + }); + } + + const { application } = request.app.locals; + const body = request.body; + + const settings = { + // Instance & Client API + instanceLanguages: (body.instanceLanguages || "en") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + maxCharacters: + parseInt(body.maxCharacters, 10) || DEFAULTS.maxCharacters, + maxMediaAttachments: + parseInt(body.maxMediaAttachments, 10) || DEFAULTS.maxMediaAttachments, + defaultVisibility: body.defaultVisibility || DEFAULTS.defaultVisibility, + defaultLanguage: (body.defaultLanguage || DEFAULTS.defaultLanguage).trim(), + + // Federation & Delivery + timelineRetention: parseInt(body.timelineRetention, 10) || 0, + notificationRetentionDays: + parseInt(body.notificationRetentionDays, 10) || 0, + activityRetentionDays: + parseInt(body.activityRetentionDays, 10) || 0, + replyChainDepth: + parseInt(body.replyChainDepth, 10) || DEFAULTS.replyChainDepth, + broadcastBatchSize: + parseInt(body.broadcastBatchSize, 10) || DEFAULTS.broadcastBatchSize, + broadcastBatchDelay: + parseInt(body.broadcastBatchDelay, 10) || DEFAULTS.broadcastBatchDelay, + parallelWorkers: + parseInt(body.parallelWorkers, 10) || DEFAULTS.parallelWorkers, + logLevel: body.logLevel || DEFAULTS.logLevel, + + // Migration + refollowBatchSize: + parseInt(body.refollowBatchSize, 10) || DEFAULTS.refollowBatchSize, + refollowDelay: + parseInt(body.refollowDelay, 10) || DEFAULTS.refollowDelay, + refollowBatchDelay: + parseInt(body.refollowBatchDelay, 10) || DEFAULTS.refollowBatchDelay, + + // Security + refreshTokenTtlDays: + parseInt(body.refreshTokenTtlDays, 10) || DEFAULTS.refreshTokenTtlDays, + }; + + await saveSettings(application.collections, settings); + + response.redirect(`${mountPath}/admin/settings?saved=true`); + } catch (error) { + next(error); + } + }; +} From 2f7e6b238b44ccc0b8a46c17e254240cbb9503c5 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:38:33 +0200 Subject: [PATCH 27/37] feat: add settings page template --- views/activitypub-settings.njk | 187 +++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 views/activitypub-settings.njk diff --git a/views/activitypub-settings.njk b/views/activitypub-settings.njk new file mode 100644 index 0000000..6859a28 --- /dev/null +++ b/views/activitypub-settings.njk @@ -0,0 +1,187 @@ +{% extends "document.njk" %} + +{% from "heading/macro.njk" import heading with context %} +{% from "input/macro.njk" import input with context %} +{% from "radios/macro.njk" import radios with context %} +{% from "button/macro.njk" import button with context %} +{% from "notification-banner/macro.njk" import notificationBanner with context %} + +{% block content %} + {% if saved %} + {{ notificationBanner({ type: "success", text: "Settings saved." }) }} + {% endif %} + + {{ heading({ text: title }) }} + + + + + {# ─── Instance & Client API ──────────────────────────────────── #} +
+ Instance & Client API +

Settings reported to Mastodon-compatible clients (Phanpy, Elk, Moshidon).

+ + {{ input({ + name: "instanceLanguages", + label: "Instance languages", + hint: "Comma-separated ISO 639-1 codes (e.g. en,fr,de). Default: " + defaults.instanceLanguages | join(","), + value: settings.instanceLanguages | join(","), + type: "text" + }) }} + + {{ input({ + name: "maxCharacters", + label: "Max characters per status", + hint: "Character limit shown to clients. Default: " + defaults.maxCharacters, + value: settings.maxCharacters, + type: "number" + }) }} + + {{ input({ + name: "maxMediaAttachments", + label: "Max media attachments", + hint: "Per-status media file limit. Default: " + defaults.maxMediaAttachments, + value: settings.maxMediaAttachments, + type: "number" + }) }} + + {{ radios({ + name: "defaultVisibility", + label: "Default post visibility", + hint: "Default visibility for new posts. Default: " + defaults.defaultVisibility, + items: [ + { value: "public", text: "Public", checked: settings.defaultVisibility == "public" }, + { value: "unlisted", text: "Unlisted", checked: settings.defaultVisibility == "unlisted" }, + { value: "private", text: "Followers only", checked: settings.defaultVisibility == "private" } + ] + }) }} + + {{ input({ + name: "defaultLanguage", + label: "Default post language", + hint: "ISO 639-1 code (e.g. en, fr). Default: " + defaults.defaultLanguage, + value: settings.defaultLanguage, + type: "text" + }) }} +
+ + {# ─── Federation & Delivery ──────────────────────────────────── #} +
+ Federation & Delivery +

Controls how content is stored, retained, and delivered to followers.

+ + {{ input({ + name: "timelineRetention", + label: "Timeline retention (items)", + hint: "Max items in the AP timeline. 0 = unlimited. Default: " + defaults.timelineRetention, + value: settings.timelineRetention, + type: "number" + }) }} + + {{ input({ + name: "notificationRetentionDays", + label: "Notification retention (days)", + hint: "Days to keep notifications. 0 = forever. Default: " + defaults.notificationRetentionDays, + value: settings.notificationRetentionDays, + type: "number" + }) }} + + {{ input({ + name: "activityRetentionDays", + label: "Activity log retention (days)", + hint: "Days to keep activity log entries. 0 = forever. Default: " + defaults.activityRetentionDays, + value: settings.activityRetentionDays, + type: "number" + }) }} + + {{ input({ + name: "replyChainDepth", + label: "Reply chain depth", + hint: "Max parent posts fetched for thread context. Default: " + defaults.replyChainDepth, + value: settings.replyChainDepth, + type: "number" + }) }} + + {{ input({ + name: "broadcastBatchSize", + label: "Broadcast batch size", + hint: "Followers per delivery batch. Default: " + defaults.broadcastBatchSize, + value: settings.broadcastBatchSize, + type: "number" + }) }} + + {{ input({ + name: "broadcastBatchDelay", + label: "Broadcast batch delay (ms)", + hint: "Delay between delivery batches in milliseconds. Default: " + defaults.broadcastBatchDelay, + value: settings.broadcastBatchDelay, + type: "number" + }) }} + + {{ input({ + name: "parallelWorkers", + label: "Parallel delivery workers", + hint: "Redis queue workers. 0 = in-process queue. Default: " + defaults.parallelWorkers, + value: settings.parallelWorkers, + type: "number" + }) }} + + {{ radios({ + name: "logLevel", + label: "Federation log level", + hint: "Fedify log verbosity. Default: " + defaults.logLevel, + items: [ + { value: "debug", text: "Debug", checked: settings.logLevel == "debug" }, + { value: "info", text: "Info", checked: settings.logLevel == "info" }, + { value: "warning", text: "Warning", checked: settings.logLevel == "warning" }, + { value: "error", text: "Error", checked: settings.logLevel == "error" } + ] + }) }} +
+ + {# ─── Migration ──────────────────────────────────────────────── #} +
+ Migration +

Controls the speed of Mastodon account re-follow processing.

+ + {{ input({ + name: "refollowBatchSize", + label: "Refollow batch size", + hint: "Accounts per refollow batch. Default: " + defaults.refollowBatchSize, + value: settings.refollowBatchSize, + type: "number" + }) }} + + {{ input({ + name: "refollowDelay", + label: "Refollow delay per follow (ms)", + hint: "Delay between individual follow requests. Default: " + defaults.refollowDelay, + value: settings.refollowDelay, + type: "number" + }) }} + + {{ input({ + name: "refollowBatchDelay", + label: "Refollow batch delay (ms)", + hint: "Delay between refollow batches. Default: " + defaults.refollowBatchDelay, + value: settings.refollowBatchDelay, + type: "number" + }) }} +
+ + {# ─── Security ───────────────────────────────────────────────── #} +
+ Security + + {{ input({ + name: "refreshTokenTtlDays", + label: "Refresh token TTL (days)", + hint: "Days before OAuth refresh tokens expire. Access tokens never expire. Default: " + defaults.refreshTokenTtlDays, + value: settings.refreshTokenTtlDays, + type: "number" + }) }} +
+ + {{ button({ text: "Save settings" }) }} + +{% endblock %} From 1797c4dbcfcfd04671c155afe217c8664013533e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:44:00 +0200 Subject: [PATCH 28/37] feat: register settings route and navigation --- index.js | 13 +++++++++++++ locales/en.json | 3 +++ 2 files changed, 16 insertions(+) diff --git a/index.js b/index.js index eec2615..75cf524 100644 --- a/index.js +++ b/index.js @@ -131,6 +131,10 @@ import { broadcastActorUpdateController, lookupObjectController, } from "./lib/controllers/federation-mgmt.js"; +import { + settingsGetController, + settingsPostController, +} from "./lib/controllers/settings.js"; const defaults = { mountPath: "/activitypub", @@ -206,6 +210,11 @@ export default class ActivityPubEndpoint { text: "activitypub.federationMgmt.title", requiresDatabase: true, }, + { + href: `${this.options.mountPath}/admin/settings`, + text: "activitypub.settings.title", + requiresDatabase: true, + }, ]; } @@ -378,6 +387,10 @@ export default class ActivityPubEndpoint { router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this)); router.get("/admin/federation/lookup", lookupObjectController(mp, this)); + // Settings + router.get("/admin/settings", settingsGetController(mp)); + router.post("/admin/settings", settingsPostController(mp)); + return router; } diff --git a/locales/en.json b/locales/en.json index e5931d2..eb70b3c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -333,6 +333,9 @@ "deleteSuccess": "Delete activity sent to followers", "deleteButton": "Delete from fediverse" }, + "settings": { + "title": "Settings" + }, "federationMgmt": { "title": "Federation", "collections": "Collection health", From d0dc5e599c2cba97507e5a3fbc98d20440979fc5 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:45:06 +0200 Subject: [PATCH 29/37] feat: add settings caching middleware for API routes --- lib/mastodon/middleware/load-settings.js | 35 ++++++++++++++++++++++++ lib/mastodon/router.js | 5 ++++ 2 files changed, 40 insertions(+) create mode 100644 lib/mastodon/middleware/load-settings.js diff --git a/lib/mastodon/middleware/load-settings.js b/lib/mastodon/middleware/load-settings.js new file mode 100644 index 0000000..af59068 --- /dev/null +++ b/lib/mastodon/middleware/load-settings.js @@ -0,0 +1,35 @@ +/** + * Settings cache middleware for Mastodon API hot paths. + * + * Loads settings once per minute (not per request) and attaches + * to req.app.locals.apSettings for all downstream handlers. + */ +import { getSettings } from "../../settings.js"; + +let cachedSettings = null; +let cacheExpiry = 0; +const CACHE_TTL = 60_000; // 1 minute + +export async function loadSettingsMiddleware(req, res, next) { + try { + const now = Date.now(); + if (cachedSettings && now < cacheExpiry) { + req.app.locals.apSettings = cachedSettings; + return next(); + } + + const collections = req.app.locals.application?.collections; + cachedSettings = await getSettings(collections); + cacheExpiry = now + CACHE_TTL; + req.app.locals.apSettings = cachedSettings; + next(); + } catch { + // On error, use defaults + if (!cachedSettings) { + const { DEFAULTS } = await import("../../settings.js"); + cachedSettings = { ...DEFAULTS }; + } + req.app.locals.apSettings = cachedSettings; + next(); + } +} diff --git a/lib/mastodon/router.js b/lib/mastodon/router.js index 8ba5140..fc616bd 100644 --- a/lib/mastodon/router.js +++ b/lib/mastodon/router.js @@ -8,6 +8,7 @@ import express from "express"; import rateLimit from "express-rate-limit"; import { corsMiddleware } from "./middleware/cors.js"; +import { loadSettingsMiddleware } from "./middleware/load-settings.js"; import { tokenRequired, optionalToken } from "./middleware/token-required.js"; import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js"; @@ -77,6 +78,10 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) { // ─── CORS ─────────────────────────────────────────────────────────────── router.use("/api", corsMiddleware); router.use("/oauth/token", corsMiddleware); + + // ─── Settings cache ──────────────────────────────────────────────────── + // Loads plugin settings once per minute, available as req.app.locals.apSettings + router.use("/api", loadSettingsMiddleware); router.use("/oauth/revoke", corsMiddleware); router.use("/.well-known/oauth-authorization-server", corsMiddleware); From 9443795dc9196a524ababb11f04d9d32f23fa858 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:49:07 +0200 Subject: [PATCH 30/37] feat: wire settings into all consumers --- lib/batch-broadcast.js | 20 +++++++++++--------- lib/batch-refollow.js | 21 +++++++++++++-------- lib/inbox-handlers.js | 4 +++- lib/mastodon/routes/accounts.js | 5 +++-- lib/mastodon/routes/instance.js | 14 ++++++++------ lib/mastodon/routes/oauth.js | 9 ++++++--- 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/lib/batch-broadcast.js b/lib/batch-broadcast.js index aae86ec..017e02c 100644 --- a/lib/batch-broadcast.js +++ b/lib/batch-broadcast.js @@ -4,9 +4,7 @@ * @module batch-broadcast */ import { logActivity } from "./activity-log.js"; - -const BATCH_SIZE = 25; -const BATCH_DELAY_MS = 5000; +import { getSettings } from "./settings.js"; /** * Broadcast an activity to all followers via batch delivery. @@ -29,6 +27,10 @@ export async function batchBroadcast({ label, objectUrl, }) { + const settings = await getSettings(collections); + const batchSize = settings.broadcastBatchSize; + const batchDelay = settings.broadcastBatchDelay; + const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl, @@ -54,11 +56,11 @@ export async function batchBroadcast({ console.info( `[ActivityPub] Broadcasting ${label} to ${uniqueRecipients.length} ` + - `unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`, + `unique inboxes (${followers.length} followers) in batches of ${batchSize}`, ); - for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) { - const batch = uniqueRecipients.slice(i, i + BATCH_SIZE); + for (let i = 0; i < uniqueRecipients.length; i += batchSize) { + const batch = uniqueRecipients.slice(i, i + batchSize); const recipients = batch.map((f) => ({ id: new URL(f.actorUrl), inboxId: new URL(f.inbox || f.sharedInbox), @@ -75,12 +77,12 @@ export async function batchBroadcast({ } catch (error) { failed += batch.length; console.warn( - `[ActivityPub] ${label} batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`, + `[ActivityPub] ${label} batch ${Math.floor(i / batchSize) + 1} failed: ${error.message}`, ); } - if (i + BATCH_SIZE < uniqueRecipients.length) { - await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS)); + if (i + batchSize < uniqueRecipients.length) { + await new Promise((resolve) => setTimeout(resolve, batchDelay)); } } diff --git a/lib/batch-refollow.js b/lib/batch-refollow.js index 583fdc8..1f4dd93 100644 --- a/lib/batch-refollow.js +++ b/lib/batch-refollow.js @@ -16,10 +16,8 @@ import { lookupWithSecurity } from "./lookup-helpers.js"; import { Follow } from "@fedify/fedify/vocab"; import { logActivity } from "./activity-log.js"; import { cacheGet, cacheSet } from "./redis-cache.js"; +import { getSettings } from "./settings.js"; -const BATCH_SIZE = 10; -const DELAY_PER_FOLLOW = 3_000; -const DELAY_BETWEEN_BATCHES = 30_000; const STARTUP_DELAY = 30_000; const RETRY_COOLDOWN = 60 * 60 * 1_000; // 1 hour const MAX_RETRIES = 3; @@ -104,7 +102,9 @@ export async function resumeBatchRefollow(options) { } await setJobState("running"); - _timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES); + const { collections: resumeCollections } = options; + const resumeSettings = await getSettings(resumeCollections); + _timer = setTimeout(() => processNextBatch(options), resumeSettings.refollowBatchDelay); console.info("[ActivityPub] Batch refollow: resumed"); } @@ -158,9 +158,14 @@ async function processNextBatch(options) { const state = await cacheGet(KV_KEY); if (state?.status !== "running") return; + const settings = await getSettings(collections); + const batchSize = settings.refollowBatchSize; + const delayPerFollow = settings.refollowDelay; + const delayBetweenBatches = settings.refollowBatchDelay; + // Claim a batch atomically: set source to "refollow:pending" const entries = []; - for (let i = 0; i < BATCH_SIZE; i++) { + for (let i = 0; i < batchSize; i++) { const doc = await collections.ap_following.findOneAndUpdate( { source: "import" }, { $set: { source: "refollow:pending" } }, @@ -172,7 +177,7 @@ async function processNextBatch(options) { // Also pick up retryable entries (failed but not permanently) const retryCutoff = new Date(Date.now() - RETRY_COOLDOWN).toISOString(); - const retrySlots = BATCH_SIZE - entries.length; + const retrySlots = batchSize - entries.length; for (let i = 0; i < retrySlots; i++) { const doc = await collections.ap_following.findOneAndUpdate( { @@ -211,14 +216,14 @@ async function processNextBatch(options) { for (const entry of entries) { await processOneFollow(options, entry); // Delay between individual follows - await sleep(DELAY_PER_FOLLOW); + await sleep(delayPerFollow); } // Update job state timestamp await setJobState("running"); // Schedule next batch - _timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES); + _timer = setTimeout(() => processNextBatch(options), delayBetweenBatches); } /** diff --git a/lib/inbox-handlers.js b/lib/inbox-handlers.js index f40ca46..72e7cd0 100644 --- a/lib/inbox-handlers.js +++ b/lib/inbox-handlers.js @@ -38,6 +38,7 @@ import { addNotification } from "./storage/notifications.js"; import { addMessage } from "./storage/messages.js"; import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js"; import { getFollowedTags } from "./storage/followed-tags.js"; +import { getSettings } from "./settings.js"; /** @type {string} ActivityStreams Public Collection constant */ const PUBLIC = "https://www.w3.org/ns/activitystreams#Public"; @@ -760,7 +761,8 @@ export async function handleCreate(item, collections, ctx, handle) { // Each ancestor is stored with isContext: true to distinguish from organic timeline items. if (inReplyTo) { try { - await fetchReplyChain(object, collections, authLoader, 5); + const settings = await getSettings(collections); + await fetchReplyChain(object, collections, authLoader, settings.replyChainDepth); } catch (error) { // Non-critical — incomplete context is acceptable console.warn("[inbox-handlers] Reply chain fetch failed:", error.message); diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 8486110..f47cbbf 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -60,10 +60,11 @@ router.get("/api/v1/accounts/verify_credentials", tokenRequired, scopeRequired(" // ─── GET /api/v1/preferences ───────────────────────────────────────────────── router.get("/api/v1/preferences", tokenRequired, scopeRequired("read", "read:accounts"), (req, res) => { + const apSettings = req.app.locals.apSettings; res.json({ - "posting:default:visibility": "public", + "posting:default:visibility": apSettings?.defaultVisibility || "public", "posting:default:sensitive": false, - "posting:default:language": "en", + "posting:default:language": apSettings?.defaultLanguage || "en", "reading:expand:media": "default", "reading:expand:spoilers": false, }); diff --git a/lib/mastodon/routes/instance.js b/lib/mastodon/routes/instance.js index 5cd7d53..7b3f31d 100644 --- a/lib/mastodon/routes/instance.js +++ b/lib/mastodon/routes/instance.js @@ -17,6 +17,7 @@ router.get("/api/v2/instance", async (req, res, next) => { const domain = req.get("host"); const collections = req.app.locals.mastodonCollections; const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const apSettings = req.app.locals.apSettings; const profile = await collections.ap_profile.findOne({}); const contactAccount = profile @@ -44,7 +45,7 @@ router.get("/api/v2/instance", async (req, res, next) => { versions: {}, }, icon: [], - languages: ["en"], + languages: apSettings?.instanceLanguages || ["en"], configuration: { urls: { streaming: "", @@ -54,8 +55,8 @@ router.get("/api/v2/instance", async (req, res, next) => { max_pinned_statuses: 10, }, statuses: { - max_characters: 5000, - max_media_attachments: 4, + max_characters: apSettings?.maxCharacters || 5000, + max_media_attachments: apSettings?.maxMediaAttachments || 4, characters_reserved_per_url: 23, }, media_attachments: { @@ -116,6 +117,7 @@ router.get("/api/v1/instance", async (req, res, next) => { const domain = req.get("host"); const collections = req.app.locals.mastodonCollections; const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const apSettings = req.app.locals.apSettings; const profile = await collections.ap_profile.findOne({}); @@ -160,14 +162,14 @@ router.get("/api/v1/instance", async (req, res, next) => { domain_count: domainCount, }, thumbnail: profile?.icon || null, - languages: ["en"], + languages: apSettings?.instanceLanguages || ["en"], registrations: false, approval_required: true, invites_enabled: false, configuration: { statuses: { - max_characters: 5000, - max_media_attachments: 4, + max_characters: apSettings?.maxCharacters || 5000, + max_media_attachments: apSettings?.maxMediaAttachments || 4, characters_reserved_per_url: 23, }, media_attachments: { diff --git a/lib/mastodon/routes/oauth.js b/lib/mastodon/routes/oauth.js index 3965263..ab75105 100644 --- a/lib/mastodon/routes/oauth.js +++ b/lib/mastodon/routes/oauth.js @@ -502,13 +502,15 @@ router.post("/oauth/token", async (req, res, next) => { // Rotate: new access token + new refresh token const newAccessToken = randomHex(64); const newRefreshToken = randomHex(64); + const refreshTtlDaysRotate = req.app.locals.apSettings?.refreshTokenTtlDays || 90; + const refreshTtlMsRotate = refreshTtlDaysRotate * 24 * 3600 * 1000; await collections.ap_oauth_tokens.updateOne( { _id: existing._id }, { $set: { accessToken: newAccessToken, refreshToken: newRefreshToken, - refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000), + refreshExpiresAt: new Date(Date.now() + refreshTtlMsRotate), }, $unset: { expiresAt: "" }, }, @@ -589,8 +591,9 @@ router.post("/oauth/token", async (req, res, next) => { // Generate access token and refresh token. // Access tokens do not expire (matching Mastodon behavior — valid until revoked). - // Refresh tokens expire after 90 days as a safety measure. - const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days + // Refresh tokens expire after a configurable number of days (default 90). + const refreshTtlDays = req.app.locals.apSettings?.refreshTokenTtlDays || 90; + const REFRESH_TOKEN_TTL = refreshTtlDays * 24 * 3600 * 1000; const accessToken = randomHex(64); const refreshToken = randomHex(64); await collections.ap_oauth_tokens.updateOne( From 55b1229667d6ac19dc1c2d1c0d2be9bcd2aa22af Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:50:25 +0200 Subject: [PATCH 31/37] docs: document settings admin page and consumer pattern --- CLAUDE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 77eb068..17af3e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,31 @@ Own posts are added to `ap_timeline` by the AP syndicator after successful deliv - `filterAction: "hide"` — status removed from response - `filterAction: "warn"` — status kept with `filtered` array attached (Mastodon v2 format) +### 40. Admin Settings Page (v3.13.0+) + +**Route:** `GET/POST {mountPath}/admin/settings` + +All configurable values are stored in a single MongoDB document in `ap_settings` collection. `lib/settings.js` provides `getSettings(collections)` which merges DB values over hardcoded defaults — missing keys always fall back. + +**Settings by section:** + +| Section | Keys | +|---|---| +| Instance & Client API | `instanceLanguages`, `maxCharacters`, `maxMediaAttachments`, `defaultVisibility`, `defaultLanguage` | +| Federation & Delivery | `timelineRetention`, `notificationRetentionDays`, `activityRetentionDays`, `replyChainDepth`, `broadcastBatchSize`, `broadcastBatchDelay`, `parallelWorkers`, `logLevel` | +| Migration | `refollowBatchSize`, `refollowDelay`, `refollowBatchDelay` | +| Security | `refreshTokenTtlDays` | + +**How consumers read settings:** +- Mastodon API routes: `req.app.locals.apSettings` (cached 1 minute by `load-settings.js` middleware) +- Non-API code (federation, inbox, batch): `await getSettings(collections)` directly + +**Adding a new setting:** +1. Add to `DEFAULTS` in `lib/settings.js` +2. Add parsing in `lib/controllers/settings.js` POST handler +3. Add form field in `views/activitypub-settings.njk` +4. Wire into the consumer file with `settings.newKey` lookup + ## Date Handling Convention **All dates MUST be stored as ISO 8601 strings.** This is mandatory across all Indiekit plugins. From fffca1efcc0877ea1091d4bf4d2245182db7a15e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 21:51:05 +0200 Subject: [PATCH 32/37] chore: bump to 3.13.0 (settings admin page) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ffe1d49..add8b6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.12.5", + "version": "3.13.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 3bd1d02cb9ee0f2c2250e3d3eac3535f170ea11c Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 22:04:06 +0200 Subject: [PATCH 33/37] chore: bump to 3.13.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index add8b6a..8fedc85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.13.0", + "version": "3.13.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From f85f2ba551326ad34dffedff5363986a9e929f57 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 22:50:46 +0200 Subject: [PATCH 34/37] =?UTF-8?q?fix:=20settings=20page=20=E2=80=94=20remo?= =?UTF-8?q?ve=20duplicate=20heading,=20fix=20radio=20button=20macro=20usag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- views/activitypub-settings.njk | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/views/activitypub-settings.njk b/views/activitypub-settings.njk index 6859a28..1170c43 100644 --- a/views/activitypub-settings.njk +++ b/views/activitypub-settings.njk @@ -11,8 +11,6 @@ {{ notificationBanner({ type: "success", text: "Settings saved." }) }} {% endif %} - {{ heading({ text: title }) }} -
@@ -47,13 +45,16 @@ {{ radios({ name: "defaultVisibility", - label: "Default post visibility", + fieldset: { + legend: "Default post visibility" + }, hint: "Default visibility for new posts. Default: " + defaults.defaultVisibility, items: [ - { value: "public", text: "Public", checked: settings.defaultVisibility == "public" }, - { value: "unlisted", text: "Unlisted", checked: settings.defaultVisibility == "unlisted" }, - { value: "private", text: "Followers only", checked: settings.defaultVisibility == "private" } - ] + { value: "public", label: "Public" }, + { value: "unlisted", label: "Unlisted" }, + { value: "private", label: "Followers only" } + ], + values: [settings.defaultVisibility] }) }} {{ input({ @@ -128,14 +129,17 @@ {{ radios({ name: "logLevel", - label: "Federation log level", + fieldset: { + legend: "Federation log level" + }, hint: "Fedify log verbosity. Default: " + defaults.logLevel, items: [ - { value: "debug", text: "Debug", checked: settings.logLevel == "debug" }, - { value: "info", text: "Info", checked: settings.logLevel == "info" }, - { value: "warning", text: "Warning", checked: settings.logLevel == "warning" }, - { value: "error", text: "Error", checked: settings.logLevel == "error" } - ] + { value: "debug", label: "Debug" }, + { value: "info", label: "Info" }, + { value: "warning", label: "Warning" }, + { value: "error", label: "Error" } + ], + values: [settings.logLevel] }) }} From 94a38d9a51931e74cd50c07530cef3ef99ca6e39 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 31 Mar 2026 22:50:59 +0200 Subject: [PATCH 35/37] chore: bump to 3.13.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8fedc85..f039954 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.13.1", + "version": "3.13.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 40eb2f8f095dd4789a85af05af26dbcdf0d9f381 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 1 Apr 2026 15:12:27 +0200 Subject: [PATCH 36/37] fix: audit fixes for account ID, update_credentials, interactions, edit payload - Account ID: use URL-based hash for all accounts (local+remote) so verify_credentials and status serialization produce matching IDs. Clients can now show edit/delete buttons on own posts. - update_credentials: pass handle+counts instead of collections to serializeCredentialAccount, add broadcastActorUpdate for federation - favourited_by/reblogged_by: query ap_notifications (incoming) instead of ap_interactions (outgoing local) for who liked/boosted a post - Status edit: send content-warning and sensitive in Micropub replace payload alongside content --- index.js | 1 + lib/mastodon/helpers/id-mapping.js | 21 +++++---- lib/mastodon/routes/accounts.js | 24 ++++++++-- lib/mastodon/routes/statuses.js | 70 ++++++++++++++++++------------ package-lock.json | 4 +- package.json | 2 +- 6 files changed, 78 insertions(+), 44 deletions(-) diff --git a/index.js b/index.js index 75cf524..b41e949 100644 --- a/index.js +++ b/index.js @@ -1119,6 +1119,7 @@ export default class ActivityPubEndpoint { federation: this._federation, followActor: (url, info) => pluginRef.followActor(url, info), unfollowActor: (url) => pluginRef.unfollowActor(url), + broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), loadRsaKey: () => pluginRef._loadRsaPrivateKey(), }, }); diff --git a/lib/mastodon/helpers/id-mapping.js b/lib/mastodon/helpers/id-mapping.js index 76e6d23..31de062 100644 --- a/lib/mastodon/helpers/id-mapping.js +++ b/lib/mastodon/helpers/id-mapping.js @@ -1,15 +1,16 @@ /** * Deterministic ID mapping for Mastodon Client API. * - * Local accounts use MongoDB _id.toString(). - * Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs - * without requiring a dedicated accounts collection. + * All accounts (local and remote) use sha256(actorUrl).slice(0, 24) + * for stable, consistent IDs. This ensures verify_credentials and + * status serialization produce the same ID for the local user, + * even though the profile doc has _id but timeline author objects don't. */ import crypto from "node:crypto"; /** - * Generate a deterministic ID for a remote actor URL. - * @param {string} actorUrl - The remote actor's URL + * Generate a deterministic ID for an actor URL. + * @param {string} actorUrl - The actor's URL * @returns {string} 24-character hex ID */ export function remoteActorId(actorUrl) { @@ -18,15 +19,13 @@ export function remoteActorId(actorUrl) { /** * Get the Mastodon API ID for an account. + * Uses URL-based hash for all accounts (local and remote) so the ID + * is consistent regardless of whether the actor object has a MongoDB _id. * @param {object} actor - Actor object (local profile or remote author) - * @param {boolean} isLocal - Whether this is the local profile + * @param {boolean} _isLocal - Unused (kept for API compatibility) * @returns {string} */ -export function accountId(actor, isLocal = false) { - if (isLocal && actor._id) { - return actor._id.toString(); - } - // Remote actors: use URL-based deterministic hash +export function accountId(actor, _isLocal = false) { const url = actor.url || actor.actorUrl || ""; return url ? remoteActorId(url) : "0"; } diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index f47cbbf..37432e4 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -306,6 +306,13 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired if (Object.keys(update).length > 0 && collections.ap_profile) { await collections.ap_profile.updateOne({}, { $set: update }); + + // Broadcast Update(Person) to followers so profile changes federate + if (pluginOptions.broadcastActorUpdate) { + pluginOptions.broadcastActorUpdate().catch((err) => + console.warn(`[Mastodon API] broadcastActorUpdate failed: ${err.message}`), + ); + } } // Return updated credential account @@ -313,12 +320,23 @@ router.patch("/api/v1/accounts/update_credentials", tokenRequired, scopeRequired ? await collections.ap_profile.findOne({}) : {}; + const handle = pluginOptions.handle || "user"; + let counts = {}; + try { + const [statuses, followers, following] = await Promise.all([ + collections.ap_timeline.countDocuments({ "author.url": profile.url }), + collections.ap_followers.countDocuments({}), + collections.ap_following.countDocuments({}), + ]); + counts = { statuses, followers, following }; + } catch { + counts = { statuses: 0, followers: 0, following: 0 }; + } + const { serializeCredentialAccount } = await import( "../entities/account.js" ); - res.json( - await serializeCredentialAccount(profile, { baseUrl, collections }), - ); + res.json(serializeCredentialAccount(profile, { baseUrl, handle, counts })); } catch (error) { next(error); } diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index fe7fd71..72c333e 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -453,7 +453,9 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write: : new URL(application.micropubEndpoint, application.url).href; const token = - req.session?.access_token || req.mastodonToken?.accessToken; + req.session?.access_token || + req.mastodonToken?.indieauthToken || + req.mastodonToken?.accessToken; if (token) { const updatePayload = { action: "update", @@ -464,6 +466,13 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write: if (statusText !== undefined) { updatePayload.replace.content = [statusText]; } + if (spoilerText !== undefined) { + updatePayload.replace["content-warning"] = spoilerText ? [spoilerText] : []; + updatePayload.replace.sensitive = [spoilerText ? "true" : "false"]; + } + if (sensitive !== undefined && spoilerText === undefined) { + updatePayload.replace.sensitive = [sensitive === true || sensitive === "true" ? "true" : "false"]; + } try { await fetch(micropubUrl, { @@ -513,13 +522,16 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write: 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 interactionState = await loadItemInteractions(collections, updated); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [updated]); - const serialized = serializeStatus(updated, { baseUrl }); + const serialized = serializeStatus(updated, { + baseUrl, + ...interactionState, + pinnedIds: new Set(), + replyIdMap, + replyAccountIdMap, + }); res.json(serialized); } catch (error) { next(error); @@ -599,23 +611,25 @@ router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("r ); 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 targetUrl = item.uid || item.url; + if (!targetUrl || !collections.ap_notifications) return res.json([]); - const interactions = await collections.ap_interactions - .find({ objectUrl: uid, type: "like" }) + // Incoming likes are stored as notifications by the inbox handler + const notifications = await collections.ap_notifications + .find({ targetUrl, type: "like" }) .limit(40) .toArray(); const { serializeAccount } = await import("../entities/account.js"); - const accounts = interactions - .filter((i) => i.actorUrl || i.actorName) - .map((i) => + const accounts = notifications + .filter((n) => n.actorUrl) + .map((n) => serializeAccount( { - url: i.actorUrl, - name: i.actorName || "", - handle: i.actorHandle || "", + url: n.actorUrl, + name: n.actorName || "", + handle: n.actorHandle || "", + photo: n.actorPhoto || "", }, { baseUrl, isLocal: false }, ), @@ -640,23 +654,25 @@ router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("re ); 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 targetUrl = item.uid || item.url; + if (!targetUrl || !collections.ap_notifications) return res.json([]); - const interactions = await collections.ap_interactions - .find({ objectUrl: uid, type: "boost" }) + // Incoming boosts are stored as notifications by the inbox handler + const notifications = await collections.ap_notifications + .find({ targetUrl, type: "boost" }) .limit(40) .toArray(); const { serializeAccount } = await import("../entities/account.js"); - const accounts = interactions - .filter((i) => i.actorUrl || i.actorName) - .map((i) => + const accounts = notifications + .filter((n) => n.actorUrl) + .map((n) => serializeAccount( { - url: i.actorUrl, - name: i.actorName || "", - handle: i.actorHandle || "", + url: n.actorUrl, + name: n.actorName || "", + handle: n.actorHandle || "", + photo: n.actorPhoto || "", }, { baseUrl, isLocal: false }, ), diff --git a/package-lock.json b/package-lock.json index c003eb2..fadd5e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.5", + "version": "3.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.5", + "version": "3.13.3", "license": "MIT", "dependencies": { "@fedify/debugger": "^2.1.0", diff --git a/package.json b/package.json index f039954..5d7e57c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.13.2", + "version": "3.13.3", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From b71b4a9dbd057a5e482184fd32721969b5f3441d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 2 Apr 2026 23:55:04 +0200 Subject: [PATCH 37/37] fix: exempt own posts from timeline retention cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup was deleting own posts when remote posts pushed the total past the retention limit. Own posts are your content — they should never be deleted by retention. Now filters to only count and delete remote posts (author.url != profile.url). --- lib/timeline-cleanup.js | 31 ++++++++++++++++++++++--------- package.json | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/timeline-cleanup.js b/lib/timeline-cleanup.js index 30335ff..cc88e7a 100644 --- a/lib/timeline-cleanup.js +++ b/lib/timeline-cleanup.js @@ -19,19 +19,32 @@ export async function cleanupTimeline(collections, retentionLimit) { return { removed: 0, interactionsRemoved: 0 }; } - const totalCount = await collections.ap_timeline.countDocuments(); - if (totalCount <= retentionLimit) { + // Get the local profile URL to exempt own posts from cleanup. + // Own posts are your content — they should never be deleted by retention. + const profile = collections.ap_profile + ? await collections.ap_profile.findOne({}) + : null; + const ownerUrl = profile?.url || null; + + // Only count remote posts toward retention limit + const remoteFilter = ownerUrl + ? { "author.url": { $ne: ownerUrl } } + : {}; + const remoteCount = await collections.ap_timeline.countDocuments(remoteFilter); + if (remoteCount <= retentionLimit) { return { removed: 0, interactionsRemoved: 0 }; } - // Use aggregation to get exact UIDs beyond the retention limit. - // This avoids race conditions: we delete by UID, not by date. + // Find remote items beyond the retention limit, sorted newest-first. + // Own posts are excluded from the aggregation pipeline entirely. + const pipeline = [ + ...(ownerUrl ? [{ $match: { "author.url": { $ne: ownerUrl } } }] : []), + { $sort: { published: -1 } }, + { $skip: retentionLimit }, + { $project: { uid: 1 } }, + ]; const toDelete = await collections.ap_timeline - .aggregate([ - { $sort: { published: -1 } }, - { $skip: retentionLimit }, - { $project: { uid: 1 } }, - ]) + .aggregate(pipeline) .toArray(); if (!toDelete.length) { diff --git a/package.json b/package.json index 5d7e57c..c19df88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.13.3", + "version": "3.13.4", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",