merge: upstream 6436763..b71b4a9 — settings page, ObjectId pagination, edit history, keyword filters

Key upstream changes merged:
- Settings admin page (ap_settings collection, GET/POST controller, Nunjucks template)
- ObjectId-based status IDs (fixes same-second collision bug in cursor pagination)
- Status edit history stored in ap_status_edits, GET /statuses/:id/history endpoint
- Keyword filters for Mastodon API timelines (apply-filters.js middleware)
- resolve-reply-ids.js helper for threading
- Filter replies from public/local/hashtag timelines
- Non-expiring access tokens
- Media upload improvements (express-fileupload, bridge IndieAuth token)
- Startup gate: defer background tasks until host readiness signal
- Own posts exempt from timeline retention cleanup
- Various Mastodon API fixes (account IDs, update_credentials, interactions)

Conflict resolutions:
- pagination.js: adopted upstream's ObjectId approach (removed cursor-based encodeCursor/decodeCursor)
- statuses.js: adopted upstream's PUT rewrite (cleaner ownership check, edit history storage via Micropub fetch); kept GET /statuses/:id with pinnedIds/replyIdMap; adopted ObjectId-only findTimelineItemById
- compose.js: kept our isDirect/senderActorUrl (DM feature) + added upstream's mediaEndpoint
- activitypub-compose.njk: kept DM notice block + added enctype="multipart/form-data"
- Removed unused processStatusContent function and Update import from statuses.js

Fork-specific changes preserved:
- allowPrivateAddress: true in federation-setup.js
- Canonical Like activity IDs + Like dispatcher in federation-setup.js
- Repost commentary in jf2-to-as2.js
- /api/ap-url endpoint in index.js
- Direct Follow workaround for tags.pub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-04-09 15:23:54 +02:00
33 changed files with 2142 additions and 455 deletions
+69
View File
@@ -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`
+63 -19
View File
@@ -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,6 +1365,23 @@ export default class ActivityPubEndpoint {
console.error("[ActivityPub] Migration separate-mentions failed:", error.message);
});
// 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;
this._stopGate = waitForReady(
() => {
// Start batch re-follow processor
startBatchRefollow(refollowOptions).catch((error) => {
console.error("[ActivityPub] Batch refollow start failed:", error.message);
});
// Schedule timeline retention cleanup (runs on startup + every 24h)
if (this.options.timelineRetention > 0) {
scheduleCleanup(this._collections, this.options.timelineRetention);
@@ -1353,9 +1393,6 @@ export default class ActivityPubEndpoint {
});
// Schedule proactive key refresh for stale follower keys (runs on startup + every 24h)
const keyRefreshHandle = this.options.actor.handle;
const keyRefreshFederation = this._federation;
const keyRefreshPubUrl = this._publicationUrl;
scheduleKeyRefresh(
this._collections,
() => keyRefreshFederation?.createContext(new URL(keyRefreshPubUrl), {
@@ -1367,8 +1404,6 @@ export default class ActivityPubEndpoint {
// 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)`);
@@ -1376,7 +1411,6 @@ export default class ActivityPubEndpoint {
}).catch((error) => {
console.warn("[Mastodon API] Timeline backfill failed:", error.message);
});
}, 5000);
});
// Start async inbox queue processor (processes one item every 3s)
@@ -1389,6 +1423,9 @@ export default class ActivityPubEndpoint {
}),
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);
}
}
}
+11 -9
View File
@@ -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));
}
}
+13 -8
View File
@@ -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);
}
/**
+17 -1
View File
@@ -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({
+92
View File
@@ -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);
}
};
}
+3 -1
View File
@@ -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);
+32
View File
@@ -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.
+1 -2
View File
@@ -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,
+29 -17
View File
@@ -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<string>} [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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
content += `\n<p>\u{1F517} <a href="${escaped}">${escaped}</a></p>`;
}
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 {
+158
View File
@@ -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<Array<{id: string, title: string, context: string[], filterAction: string, expiresAt: string|null, regex: RegExp|null, keywords: Array}>>}
*/
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<object>} statuses - Serialized Mastodon Status objects
* @param {Array<object>} filters - Compiled filter objects from loadUserFilters()
* @returns {Array<object>} 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;
}
+10 -11
View File
@@ -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";
}
+48 -69
View File
@@ -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(", "));
+46
View File
@@ -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<object>} items - Timeline items with optional inReplyTo
* @returns {Promise<{replyIdMap: Map<string, string>, replyAccountIdMap: Map<string, string>}>}
*/
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 };
}
+35
View File
@@ -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();
}
}
+16 -2
View File
@@ -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 ──────────────────────────────
+116 -2
View File
@@ -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) => {
+216
View File
@@ -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;
+8 -6
View File
@@ -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: {
+234 -19
View File
@@ -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;
+12 -10
View File
@@ -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);
+350 -206
View File
@@ -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: `<p>${contentHtml}</p>`,
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(
/(https?:\/\/[^\s<>&"')\]]+)/g,
'<a href="$1">$1</a>',
)
.replace(/\n/g, "<br>");
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}`);
}
// 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,
// 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: [],
});
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<object|null>} 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 <p>, process it
// Don't touch HTML that already has links (from Micropub rendering)
if (!html.includes("<a ")) {
// Linkify bare URLs (http/https)
html = html.replace(
/(https?:\/\/[^\s<>"')\]]+)/g,
(_, url) => {
// Strip trailing punctuation that is almost never part of a URL
const clean = url.replace(/[.,;:!?]+$/, "");
return `<a href="${clean}" rel="nofollow noopener noreferrer" target="_blank">${clean}</a>`;
},
);
// 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,
`<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@${username}@${domain}</a></span>`,
),
);
}
return {
text: content.text || rawText || "",
html,
};
}
/**
* Extract @user@domain mentions from text into mention objects.
*
* @param {string} text - Status text
+104 -10
View File
@@ -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) => {
+42 -4
View File
@@ -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);
}
+72
View File
@@ -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<object>} 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 },
);
}
+32 -1
View File
@@ -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 <a> 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(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
const parts = handle.split("@");
const url = profileUrl || (actorUrl ? actorUrl : `https://${parts[1]}/@${parts[0]}`);
content.html = content.html.replace(
pattern,
`<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
);
}
}
// 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 };
}
+20 -7
View File
@@ -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.
const toDelete = await collections.ap_timeline
.aggregate([
// 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(pipeline)
.toArray();
if (!toDelete.length) {
+2 -2
View File
@@ -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();
+3
View File
@@ -333,6 +333,9 @@
"deleteSuccess": "Delete activity sent to followers",
"deleteButton": "Delete from fediverse"
},
"settings": {
"title": "Settings"
},
"federationMgmt": {
"title": "Federation",
"collections": "Collection health",
+9 -2
View File
@@ -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",
+2 -1
View File
@@ -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",
+46 -6
View File
@@ -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 @@
</div>
{% endif %}
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form">
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{% if replyTo %}
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
@@ -49,12 +52,49 @@
style="display: none">
</div>
{# Content textarea #}
{# Rich content editor (EasyMDE with media browser) #}
<div class="ap-compose__editor">
<textarea name="content" class="ap-compose__textarea"
rows="6"
placeholder="{{ __('activitypub.compose.placeholder') }}"
required></textarea>
{{ 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
}
}
}) }}
</div>
{# Featured image #}
<div class="ap-compose__media">
{{ fileInput({
name: "photo",
label: "Photo",
field: {
attributes: {
endpoint: mediaEndpoint
}
},
accept: "image/*",
attributes: {
placeholder: "https://"
}
}) }}
</div>
{# Tags / categories #}
<div class="ap-compose__tags">
{{ tagInput({
name: "category",
label: "Tags",
optional: true,
hint: "Separate with commas"
}) }}
</div>
{# Visibility — hidden for direct messages #}
+191
View File
@@ -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 %}
<form method="POST" action="{{ mountPath }}/admin/settings">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{# ─── Instance & Client API ──────────────────────────────────── #}
<fieldset class="fieldset">
<legend class="fieldset__legend">Instance &amp; Client API</legend>
<p class="hint">Settings reported to Mastodon-compatible clients (Phanpy, Elk, Moshidon).</p>
{{ 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"
}) }}
</fieldset>
{# ─── Federation & Delivery ──────────────────────────────────── #}
<fieldset class="fieldset">
<legend class="fieldset__legend">Federation &amp; Delivery</legend>
<p class="hint">Controls how content is stored, retained, and delivered to followers.</p>
{{ 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]
}) }}
</fieldset>
{# ─── Migration ──────────────────────────────────────────────── #}
<fieldset class="fieldset">
<legend class="fieldset__legend">Migration</legend>
<p class="hint">Controls the speed of Mastodon account re-follow processing.</p>
{{ 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"
}) }}
</fieldset>
{# ─── Security ───────────────────────────────────────────────── #}
<fieldset class="fieldset">
<legend class="fieldset__legend">Security</legend>
{{ 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"
}) }}
</fieldset>
{{ button({ text: "Save settings" }) }}
</form>
{% endblock %}