diff --git a/CLAUDE.md b/CLAUDE.md index 0c3cf1f..7f1ad4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,6 +390,66 @@ Note: tags.pub does not send `Accept(Follow)` back and `@_followback@tags.pub` d `syncCollection: true` on `sendActivity()` attaches `Collection-Synchronization` headers. The **receiving side** (parsing inbound headers, reconciliation) is NOT implemented. Full compliance would require a `/followers-sync` endpoint. +### 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. + +**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) + +### 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.** The Nunjucks `| date` filter calls `date-fns parseISO()` which only accepts ISO strings — `Date` objects cause `"dateString.split is not a function"` crashes. @@ -539,6 +599,15 @@ On restart, `refollow:pending` entries reset to `import` to prevent stale claims } ``` +## 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. Bump version in `package.json` diff --git a/index.js b/index.js index 8c5d594..ebdb9cc 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"; @@ -131,6 +132,10 @@ import { broadcastActorUpdateController, lookupObjectController, } from "./lib/controllers/federation-mgmt.js"; +import { + settingsGetController, + settingsPostController, +} from "./lib/controllers/settings.js"; const defaults = { mountPath: "/activitypub", @@ -206,6 +211,11 @@ export default class ActivityPubEndpoint { text: "activitypub.federationMgmt.title", requiresDatabase: true, }, + { + href: `${this.options.mountPath}/admin/settings`, + text: "activitypub.settings.title", + requiresDatabase: true, + }, ]; } @@ -473,6 +483,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; } @@ -1180,6 +1194,18 @@ 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"); + + // Plugin settings (single document, admin UI at /admin/settings) + Indiekit.addCollection("ap_settings"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; @@ -1218,6 +1244,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"); }, @@ -1306,6 +1341,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(), broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), }, @@ -1319,19 +1355,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 }) => { @@ -1342,52 +1365,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" }, ); } @@ -1421,4 +1458,11 @@ export default class ActivityPubEndpoint { await ap_profile.insertOne(profile); } + + destroy() { + this._stopGate?.(); + if (this._inboxProcessorInterval) { + clearInterval(this._inboxProcessorInterval); + } + } } 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/controllers/compose.js b/lib/controllers/compose.js index dfaa007..c160c06 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -165,6 +165,7 @@ export function composeController(mountPath, plugin) { mountPath, isDirect, senderActorUrl, + mediaEndpoint: application.mediaEndpoint || "", }); } catch (error) { next(error); @@ -188,7 +189,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"]; @@ -340,6 +341,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/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); + } + }; +} 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/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/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 069b92a..c38fcdd 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -15,7 +15,7 @@ */ import { serializeAccount } from "./account.js"; import { sanitizeHtml } from "./sanitize.js"; -import { encodeCursor } from "../helpers/pagination.js"; +import { remoteActorId } from "../helpers/id-mapping.js"; // Module-level defaults set once at startup via setLocalIdentity() let _localPublicationUrl = ""; @@ -44,13 +44,11 @@ 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, 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; @@ -102,7 +100,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"; @@ -171,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) => ({ @@ -204,8 +216,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: replyAccountIdMap?.get(item.inReplyTo) ?? null, sensitive, spoiler_text: spoilerText, visibility, @@ -246,7 +258,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/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/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/helpers/pagination.js b/lib/mastodon/helpers/pagination.js index 98ff388..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 ""; - const ms = new Date(published).getTime(); - return Number.isFinite(ms) && ms > 0 ? String(ms) : ""; -} - -/** - * 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 new file mode 100644 index 0000000..cf57e1b --- /dev/null +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -0,0 +1,46 @@ +/** + * 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 → 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 { remoteActorId } from "./id-mapping.js"; + +export async function resolveReplyIds(collection, items) { + const replyIdMap = new Map(); + const replyAccountIdMap = new Map(); + if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap }; + + const urls = [ + ...new Set( + items.map((item) => item.inReplyTo).filter(Boolean), + ), + ]; + if (urls.length === 0) return { replyIdMap, replyAccountIdMap }; + + const parents = await collection + .find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] }) + .project({ uid: 1, url: 1, "author.url": 1 }) + .toArray(); + + for (const parent of parents) { + const parentId = parent._id.toString(); + const authorUrl = parent.author?.url; + const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null; + + const setMaps = (key) => { + replyIdMap.set(key, parentId); + if (authorAccountId) replyAccountIdMap.set(key, authorAccountId); + }; + + if (parent.uid) setMaps(parent.uid); + if (parent.url && parent.url !== parent.uid) setMaps(parent.url); + } + + return { replyIdMap, replyAccountIdMap }; +} 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 3a28efa..fde5a17 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"; @@ -20,9 +21,13 @@ 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 +// 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, @@ -30,6 +35,7 @@ const apiLimiter = rateLimit({ legacyHeaders: false, validate: { trustProxy: false }, // behind nginx reverse proxy; trust proxy is intentional message: { error: "Too many requests, please try again later" }, + validate: { trustProxy: false }, }); const authLimiter = rateLimit({ @@ -39,6 +45,7 @@ const authLimiter = rateLimit({ legacyHeaders: false, validate: { trustProxy: false }, message: { error: "Too many authentication attempts" }, + validate: { trustProxy: false }, }); const appRegistrationLimiter = rateLimit({ @@ -48,6 +55,7 @@ const appRegistrationLimiter = rateLimit({ legacyHeaders: false, validate: { trustProxy: false }, message: { error: "Too many app registrations" }, + validate: { trustProxy: false }, }); /** @@ -63,7 +71,8 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) { // ─── Body parsers ─────────────────────────────────────────────────────── // Mastodon clients send JSON, form-urlencoded, and occasionally text/plain. - // These must be applied before route handlers. + // 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()); @@ -72,6 +81,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); @@ -115,6 +128,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 caeecd8..a8b766e 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, }); @@ -153,6 +154,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 +284,64 @@ 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 }); + + // 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 + const profile = collections.ap_profile + ? 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(serializeCredentialAccount(profile, { baseUrl, handle, counts })); + } 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/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/media.js b/lib/mastodon/routes/media.js index 31e0bee..d7f1e7f 100644 --- a/lib/mastodon/routes/media.js +++ b/lib/mastodon/routes/media.js @@ -1,45 +1,260 @@ /** * 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) + * + * 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 { 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 +/** + * 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. + * Accepts an express-fileupload file object (has .data Buffer, .mimetype, .name). + * 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.data], { type: file.mimetype }); + formData.append("file", blob, file.name); + + 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"), + async (req, res, next) => { + 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?.indieauthToken || + req.mastodonToken?.accessToken; + + const file = req.files?.file; + if (!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(file, application, token); + + const doc = { + url: fileUrl, + description: req.body.description || "", + focus: req.body.focus || null, + mimeType: 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"), + async (req, res, next) => { + 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?.indieauthToken || + req.mastodonToken?.accessToken; + + const file = req.files?.file; + if (!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(file, application, token); + + const doc = { + url: fileUrl, + description: req.body.description || "", + focus: req.body.focus || null, + mimeType: 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/oauth.js b/lib/mastodon/routes/oauth.js index 2ca8a87..2a31c1c 100644 --- a/lib/mastodon/routes/oauth.js +++ b/lib/mastodon/routes/oauth.js @@ -393,6 +393,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 }); @@ -470,7 +473,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({ @@ -478,7 +480,6 @@ router.post("/oauth/token", async (req, res, next) => { token_type: "Bearer", scope: "read", created_at: Math.floor(Date.now() / 1000), - expires_in: 3600, }); } @@ -507,15 +508,17 @@ 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, - expiresAt: new Date(Date.now() + 3600 * 1000), - refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000), + refreshExpiresAt: new Date(Date.now() + refreshTtlMsRotate), }, + $unset: { expiresAt: "" }, }, ); @@ -525,7 +528,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, }); } @@ -593,9 +595,11 @@ router.post("/oauth/token", async (req, res, next) => { } } - // Generate access token and refresh token with expiry. - const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour - const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days + // Generate access token and refresh token. + // Access tokens do not expire (matching Mastodon behavior — valid until revoked). + // 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( @@ -604,7 +608,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), }, }, @@ -616,7 +619,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/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index ce374ad..414d6e3 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 @@ -17,10 +19,10 @@ */ import crypto from "node:crypto"; import express from "express"; -import { Note, Create, Mention, Update } from "@fedify/fedify/vocab"; +import { Note, Create, Mention } from "@fedify/fedify/vocab"; 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, @@ -49,8 +51,15 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st // Load interaction state if authenticated const interactionState = await loadItemInteractions(collections, item); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [item]); - const status = serializeStatus(item, { baseUrl, ...interactionState }); + const status = serializeStatus(item, { + baseUrl, + ...interactionState, + pinnedIds: new Set(), + replyIdMap, + replyAccountIdMap, + }); res.json(status); } catch (error) { @@ -124,7 +133,9 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", pinnedIds: new Set(), }; - const serializeOpts = { baseUrl, ...emptyInteractions }; + const allItems = [...ancestors, ...descendants]; + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); + const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap }; res.json({ ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), @@ -146,6 +157,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, @@ -169,6 +193,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; @@ -369,6 +415,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({ url: 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"); @@ -385,7 +445,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}

`, @@ -428,7 +488,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); } @@ -484,7 +557,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) { @@ -498,20 +571,25 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri }); // ─── PUT /api/v1/statuses/:id ─────────────────────────────────────────────── -// Edit a post: update content via Micropub pipeline, patch ap_timeline, -// and broadcast an AP Update(Note) to followers. +// Edit an existing status. Stores the previous version for history. -router.put("/api/v1/statuses/:id", async (req, res, next) => { +router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => { try { - const token = req.mastodonToken; - if (!token) { - return res.status(401).json({ error: "The access token is invalid" }); - } - const { application, publication } = req.app.locals; const collections = req.app.locals.mastodonCollections; const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const baseUrl = `${req.protocol}://${req.get("host")}`; + const 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, @@ -520,100 +598,171 @@ router.put("/api/v1/statuses/:id", async (req, res, next) => { language, } = req.body; - if (statusText === undefined) { - return res.status(422).json({ error: "Validation failed: Text content is required" }); + + // 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?.indieauthToken || + req.mastodonToken?.accessToken; + if (token) { + const updatePayload = { + action: "update", + url: postUrl, + replace: {}, + }; + + 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, { + 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 interactionState = await loadItemInteractions(collections, updated); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [updated]); + + const serialized = serializeStatus(updated, { + baseUrl, + ...interactionState, + pinnedIds: new Set(), + replyIdMap, + replyAccountIdMap, + }); + 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" }); } - // Verify ownership — only allow editing own posts - const profile = await collections.ap_profile.findOne({}); - if (profile && item.author?.url !== profile.url) { - return res.status(403).json({ error: "This action is not allowed" }); - } + const edits = collections.ap_status_edits + ? await collections.ap_status_edits + .find({ statusId: req.params.id }) + .sort({ editedAt: 1 }) + .toArray() + : []; - const postUrl = item.uid || item.url; - const now = new Date().toISOString(); + const { serializeAccount } = await import("../entities/account.js"); + const localPublicationUrl = pluginOptions.publicationUrl || baseUrl; + const handle = pluginOptions.actor?.handle || ""; - // Update via Micropub pipeline (updates MongoDB posts + content file) - let updatedContent = processStatusContent({ text: statusText, html: "" }, statusText); - try { - const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js"); - const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js"); + const accountObj = item.author + ? serializeAccount(item.author, { + baseUrl, + isLocal: item.author.url === localPublicationUrl, + handle, + }) + : null; - const operation = { replace: { content: [statusText] } }; - if (spoilerText !== undefined) operation.replace.summary = [spoilerText]; - if (sensitive !== undefined) operation.replace.sensitive = [String(sensitive)]; - if (language !== undefined) operation.replace["mp-language"] = [language]; + // 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: [], + })); - const updatedPost = await postData.update(application, publication, postUrl, operation); - if (updatedPost) { - await postContent.update(publication, updatedPost, postUrl); - const rawContent = updatedPost.properties?.content; - if (rawContent) { - updatedContent = processStatusContent( - typeof rawContent === "string" ? { text: rawContent, html: "" } : rawContent, - statusText, - ); - } - } - } catch (err) { - console.warn(`[Mastodon API] Micropub update failed for ${postUrl}: ${err.message}`); - } + // 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: [], + }); - // Patch the ap_timeline document - const newSummary = spoilerText !== undefined ? spoilerText : (item.summary || ""); - const newSensitive = sensitive !== undefined - ? (sensitive === true || sensitive === "true") - : (item.sensitive || false); - await collections.ap_timeline.updateOne( - { _id: item._id }, - { $set: { content: updatedContent, summary: newSummary, sensitive: newSensitive, updatedAt: now } }, - ); - const updatedItem = { ...item, content: updatedContent, summary: newSummary, sensitive: newSensitive, updatedAt: now }; - - // Broadcast AP Update(Note) to followers (best-effort) - try { - const federation = pluginOptions.federation; - const handle = pluginOptions.handle || "user"; - const publicationUrl = pluginOptions.publicationUrl || baseUrl; - if (federation) { - const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl }); - const actorUri = ctx.getActorUri(handle); - const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public"); - const followersUri = ctx.getFollowersUri(handle); - const note = new Note({ - id: new URL(postUrl), - attributedTo: actorUri, - content: updatedContent.html || updatedContent.text || statusText, - summary: newSummary || null, - sensitive: newSensitive, - published: item.published ? new Date(item.published) : null, - updated: new Date(now), - to: publicAddress, - cc: followersUri, - }); - const updateActivity = new Update({ - actor: actorUri, - object: note, - to: publicAddress, - cc: followersUri, - }); - await ctx.sendActivity({ identifier: handle }, "followers", updateActivity, { - preferSharedInbox: true, - orderingKey: postUrl, - }); - console.info(`[Mastodon API] Sent Update(Note) for ${postUrl}`); - } - } catch (err) { - console.warn(`[Mastodon API] AP Update broadcast failed for ${postUrl}: ${err.message}`); - } - - const interactionState = await loadItemInteractions(collections, updatedItem); - res.json(serializeStatus(updatedItem, { baseUrl, ...interactionState })); + res.json(history); } catch (error) { next(error); } @@ -621,16 +770,88 @@ router.put("/api/v1/statuses/:id", async (req, res, next) => { // ─── 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 targetUrl = item.uid || item.url; + if (!targetUrl || !collections.ap_notifications) return res.json([]); + + // 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 = notifications + .filter((n) => n.actorUrl) + .map((n) => + serializeAccount( + { + url: n.actorUrl, + name: n.actorName || "", + handle: n.actorHandle || "", + photo: n.actorPhoto || "", + }, + { 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 targetUrl = item.uid || item.url; + if (!targetUrl || !collections.ap_notifications) return res.json([]); + + // 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 = notifications + .filter((n) => n.actorUrl) + .map((n) => + serializeAccount( + { + url: n.actorUrl, + name: n.actorName || "", + handle: n.actorHandle || "", + photo: n.actorPhoto || "", + }, + { baseUrl, isLocal: false }, + ), + ); + + res.json(accounts); + } catch (error) { + next(error); + } }); // ─── POST /api/v1/statuses/:id/favourite ──────────────────────────────────── @@ -854,73 +1075,41 @@ router.post("/api/v1/statuses/:id/unpin", async (req, res, next) => { } }); +// ─── 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 ───────────────────────────────────────────────────────────────── /** - * 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 UTC ISO match (e.g., "2026-03-21T15:33:50.000Z") - 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; - } - - // Try BSON Date (Micropub pipeline stores published as Date objects) - item = await collection.findOne({ published: new Date(publishedDate) }); - if (item) return item; - - // Try ±1 s range lookup for timezone-offset stored strings (+01:00 etc.) - // and BSON Date fields. The UTC-ISO string range query used above fails when - // the stored value has a non-UTC timezone — "2026-03-21T16:33:50+01:00" is - // lexicographically outside ["2026-03-21T15:33:50Z", "2026-03-21T15:33:51Z"]. - // $dateFromString parses any ISO 8601 format (including offsets) to a Date, - // $toLong converts it to ms-since-epoch, and the numeric range always matches. - const ms = Number.parseInt(id, 10); - if (ms > 0) { - const lo = new Date(ms - 999); - const hi = new Date(ms + 999); - item = await collection.findOne({ - $or: [ - // BSON Date stored (Micropub pipeline) — direct Date range comparison - { published: { $gte: lo, $lte: hi } }, - // String stored with any timezone format — parse via $dateFromString - { - $expr: { - $and: [ - { $gte: [ - { $toLong: { $dateFromString: { dateString: "$published", onError: 0, onNull: 0 } } }, - ms - 999, - ] }, - { $lte: [ - { $toLong: { $dateFromString: { dateString: "$published", onError: 0, onNull: 0 } } }, - ms + 999, - ] }, - ], - }, - }, - ], - }); - if (item) return item; - } - } - - // Fall back to ObjectId lookup (legacy IDs) try { return await collection.findOne({ _id: new ObjectId(id) }); } catch { @@ -989,51 +1178,6 @@ async function loadItemInteractions(collections, item) { } /** - - * Process status content: linkify bare URLs and convert @mentions to links. - * - * Mastodon clients send plain text — the server is responsible for - * converting URLs and mentions into HTML links. - * - * @param {object} content - { text, html } from Micropub pipeline - * @param {string} rawText - Original status text from client - * @returns {object} { text, html } with linkified content - */ -function processStatusContent(content, rawText) { - let html = content.html || content.text || rawText || ""; - - // If the HTML is just plain text wrapped in

, process it - // Don't touch HTML that already has links (from Micropub rendering) - if (!html.includes(""')\]]+)/g, - (_, url) => { - // Strip trailing punctuation that is almost never part of a URL - const clean = url.replace(/[.,;:!?]+$/, ""); - return `${clean}`; - }, - ); - - // Convert @user@domain mentions to profile links - html = html.replace( - /(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g, - (match, full, username, domain) => - match.replace( - full, - `@${username}@${domain}`, - ), - ); - } - - return { - text: content.text || rawText || "", - html, - }; -} - -/** - * Extract @user@domain mentions from text into mention objects. * * @param {string} text - Status text 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/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 991565e..c3696f7 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -8,8 +8,10 @@ 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 { loadUserFilters, applyFilters } from "../helpers/apply-filters.js"; import { tokenRequired } from "../middleware/token-required.js"; import { scopeRequired } from "../middleware/scope-required.js"; @@ -63,6 +65,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: items, ); + // Resolve reply parent IDs for threading + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, items); + // Serialize to Mastodon Status entities const statuses = items.map((item) => serializeStatus(item, { @@ -71,6 +76,8 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap, + replyAccountIdMap, }), ); @@ -79,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); } @@ -96,9 +110,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", }; @@ -163,6 +178,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { )); } + const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items); + const statuses = items.map((item) => serializeStatus(item, { baseUrl, @@ -170,14 +187,23 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap: rIdMap, + replyAccountIdMap: rAcctMap, }), ); 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); } @@ -194,6 +220,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, }; @@ -226,6 +253,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { )); } + const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items); + const statuses = items.map((item) => serializeStatus(item, { baseUrl, @@ -233,14 +262,23 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap: rIdMap, + replyAccountIdMap: rAcctMap, }), ); 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); } 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 }, + ); +} diff --git a/lib/syndicator.js b/lib/syndicator.js index 92b7cef..9a39753 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -225,11 +225,41 @@ export function createSyndicator(plugin) { try { const profile = await plugin._collections.ap_profile?.findOne({}); 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, @@ -344,7 +374,8 @@ function buildTimelineContent(properties) { }; } - // Regular post — return body content as-is + // Regular post — return body content as-is. + // Permalink is appended by the caller (syndicator) for ALL post types. if (bodyText || bodyHtml) { return { text: bodyText, html: bodyHtml }; } 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/lib/timeline-store.js b/lib/timeline-store.js index c2ab108..5b59a18 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -219,9 +219,9 @@ export async function extractObjectData(object, options = {}) { pollClosed = closedValue === true || (closedValue != null && closedValue !== false); } - // Published date — store as UTC ISO string so cursor-based lookups always match. + // Published date — store as UTC ISO string (Z-suffix) for consistency. // String(Temporal.Instant) preserves the original timezone offset (e.g. +01:00); - // normalizing via new Date() converts to Z-suffix UTC, matching decodeCursor output. + // normalizing via new Date() converts to Z-suffix UTC. const published = object.published ? new Date(String(object.published)).toISOString() : new Date().toISOString(); 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", diff --git a/package-lock.json b/package-lock.json index 72fe5a0..fadd5e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.9.4", + "version": "3.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.9.4", + "version": "3.13.3", "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", @@ -1396,6 +1397,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", diff --git a/package.json b/package.json index 1955000..c19df88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.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", @@ -41,6 +41,7 @@ "@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", diff --git a/views/activitypub-compose.njk b/views/activitypub-compose.njk index 5a924f6..f784839 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 #} @@ -27,7 +30,7 @@ {% endif %} -
+ {% if replyTo %} @@ -49,12 +52,49 @@ style="display: none"> - {# Content textarea #} + {# Rich content editor (EasyMDE with media browser) #}
- + {{ textarea({ + name: "content", + 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({ + name: "photo", + label: "Photo", + field: { + attributes: { + endpoint: mediaEndpoint + } + }, + accept: "image/*", + attributes: { + placeholder: "https://" + } + }) }} +
+ + {# Tags / categories #} +
+ {{ tagInput({ + name: "category", + label: "Tags", + optional: true, + hint: "Separate with commas" + }) }}
{# Visibility — hidden for direct messages #} diff --git a/views/activitypub-settings.njk b/views/activitypub-settings.njk new file mode 100644 index 0000000..1170c43 --- /dev/null +++ b/views/activitypub-settings.njk @@ -0,0 +1,191 @@ +{% 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 %} + + + + + {# ─── 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", + fieldset: { + legend: "Default post visibility" + }, + hint: "Default visibility for new posts. Default: " + defaults.defaultVisibility, + items: [ + { value: "public", label: "Public" }, + { value: "unlisted", label: "Unlisted" }, + { value: "private", label: "Followers only" } + ], + values: [settings.defaultVisibility] + }) }} + + {{ 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", + fieldset: { + legend: "Federation log level" + }, + hint: "Fedify log verbosity. Default: " + defaults.logLevel, + items: [ + { value: "debug", label: "Debug" }, + { value: "info", label: "Info" }, + { value: "warning", label: "Warning" }, + { value: "error", label: "Error" } + ], + values: [settings.logLevel] + }) }} +
+ + {# ─── 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 %}