@rmdes/indiekit-endpoint-activitypub 3.12.4 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -131,6 +131,10 @@ import {
131
131
  broadcastActorUpdateController,
132
132
  lookupObjectController,
133
133
  } from "./lib/controllers/federation-mgmt.js";
134
+ import {
135
+ settingsGetController,
136
+ settingsPostController,
137
+ } from "./lib/controllers/settings.js";
134
138
 
135
139
  const defaults = {
136
140
  mountPath: "/activitypub",
@@ -206,6 +210,11 @@ export default class ActivityPubEndpoint {
206
210
  text: "activitypub.federationMgmt.title",
207
211
  requiresDatabase: true,
208
212
  },
213
+ {
214
+ href: `${this.options.mountPath}/admin/settings`,
215
+ text: "activitypub.settings.title",
216
+ requiresDatabase: true,
217
+ },
209
218
  ];
210
219
  }
211
220
 
@@ -378,6 +387,10 @@ export default class ActivityPubEndpoint {
378
387
  router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this));
379
388
  router.get("/admin/federation/lookup", lookupObjectController(mp, this));
380
389
 
390
+ // Settings
391
+ router.get("/admin/settings", settingsGetController(mp));
392
+ router.post("/admin/settings", settingsPostController(mp));
393
+
381
394
  return router;
382
395
  }
383
396
 
@@ -969,6 +982,9 @@ export default class ActivityPubEndpoint {
969
982
  Indiekit.addCollection("ap_filters");
970
983
  Indiekit.addCollection("ap_filter_keywords");
971
984
 
985
+ // Plugin settings (single document, admin UI at /admin/settings)
986
+ Indiekit.addCollection("ap_settings");
987
+
972
988
  // Store collection references (posts resolved lazily)
973
989
  const indiekitCollections = Indiekit.collections;
974
990
  this._collections = {
@@ -4,9 +4,7 @@
4
4
  * @module batch-broadcast
5
5
  */
6
6
  import { logActivity } from "./activity-log.js";
7
-
8
- const BATCH_SIZE = 25;
9
- const BATCH_DELAY_MS = 5000;
7
+ import { getSettings } from "./settings.js";
10
8
 
11
9
  /**
12
10
  * Broadcast an activity to all followers via batch delivery.
@@ -29,6 +27,10 @@ export async function batchBroadcast({
29
27
  label,
30
28
  objectUrl,
31
29
  }) {
30
+ const settings = await getSettings(collections);
31
+ const batchSize = settings.broadcastBatchSize;
32
+ const batchDelay = settings.broadcastBatchDelay;
33
+
32
34
  const ctx = federation.createContext(new URL(publicationUrl), {
33
35
  handle,
34
36
  publicationUrl,
@@ -54,11 +56,11 @@ export async function batchBroadcast({
54
56
 
55
57
  console.info(
56
58
  `[ActivityPub] Broadcasting ${label} to ${uniqueRecipients.length} ` +
57
- `unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
59
+ `unique inboxes (${followers.length} followers) in batches of ${batchSize}`,
58
60
  );
59
61
 
60
- for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
61
- const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
62
+ for (let i = 0; i < uniqueRecipients.length; i += batchSize) {
63
+ const batch = uniqueRecipients.slice(i, i + batchSize);
62
64
  const recipients = batch.map((f) => ({
63
65
  id: new URL(f.actorUrl),
64
66
  inboxId: new URL(f.inbox || f.sharedInbox),
@@ -75,12 +77,12 @@ export async function batchBroadcast({
75
77
  } catch (error) {
76
78
  failed += batch.length;
77
79
  console.warn(
78
- `[ActivityPub] ${label} batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
80
+ `[ActivityPub] ${label} batch ${Math.floor(i / batchSize) + 1} failed: ${error.message}`,
79
81
  );
80
82
  }
81
83
 
82
- if (i + BATCH_SIZE < uniqueRecipients.length) {
83
- await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
84
+ if (i + batchSize < uniqueRecipients.length) {
85
+ await new Promise((resolve) => setTimeout(resolve, batchDelay));
84
86
  }
85
87
  }
86
88
 
@@ -16,10 +16,8 @@ import { lookupWithSecurity } from "./lookup-helpers.js";
16
16
  import { Follow } from "@fedify/fedify/vocab";
17
17
  import { logActivity } from "./activity-log.js";
18
18
  import { cacheGet, cacheSet } from "./redis-cache.js";
19
+ import { getSettings } from "./settings.js";
19
20
 
20
- const BATCH_SIZE = 10;
21
- const DELAY_PER_FOLLOW = 3_000;
22
- const DELAY_BETWEEN_BATCHES = 30_000;
23
21
  const STARTUP_DELAY = 30_000;
24
22
  const RETRY_COOLDOWN = 60 * 60 * 1_000; // 1 hour
25
23
  const MAX_RETRIES = 3;
@@ -104,7 +102,9 @@ export async function resumeBatchRefollow(options) {
104
102
  }
105
103
 
106
104
  await setJobState("running");
107
- _timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES);
105
+ const { collections: resumeCollections } = options;
106
+ const resumeSettings = await getSettings(resumeCollections);
107
+ _timer = setTimeout(() => processNextBatch(options), resumeSettings.refollowBatchDelay);
108
108
  console.info("[ActivityPub] Batch refollow: resumed");
109
109
  }
110
110
 
@@ -158,9 +158,14 @@ async function processNextBatch(options) {
158
158
  const state = await cacheGet(KV_KEY);
159
159
  if (state?.status !== "running") return;
160
160
 
161
+ const settings = await getSettings(collections);
162
+ const batchSize = settings.refollowBatchSize;
163
+ const delayPerFollow = settings.refollowDelay;
164
+ const delayBetweenBatches = settings.refollowBatchDelay;
165
+
161
166
  // Claim a batch atomically: set source to "refollow:pending"
162
167
  const entries = [];
163
- for (let i = 0; i < BATCH_SIZE; i++) {
168
+ for (let i = 0; i < batchSize; i++) {
164
169
  const doc = await collections.ap_following.findOneAndUpdate(
165
170
  { source: "import" },
166
171
  { $set: { source: "refollow:pending" } },
@@ -172,7 +177,7 @@ async function processNextBatch(options) {
172
177
 
173
178
  // Also pick up retryable entries (failed but not permanently)
174
179
  const retryCutoff = new Date(Date.now() - RETRY_COOLDOWN).toISOString();
175
- const retrySlots = BATCH_SIZE - entries.length;
180
+ const retrySlots = batchSize - entries.length;
176
181
  for (let i = 0; i < retrySlots; i++) {
177
182
  const doc = await collections.ap_following.findOneAndUpdate(
178
183
  {
@@ -211,14 +216,14 @@ async function processNextBatch(options) {
211
216
  for (const entry of entries) {
212
217
  await processOneFollow(options, entry);
213
218
  // Delay between individual follows
214
- await sleep(DELAY_PER_FOLLOW);
219
+ await sleep(delayPerFollow);
215
220
  }
216
221
 
217
222
  // Update job state timestamp
218
223
  await setJobState("running");
219
224
 
220
225
  // Schedule next batch
221
- _timer = setTimeout(() => processNextBatch(options), DELAY_BETWEEN_BATCHES);
226
+ _timer = setTimeout(() => processNextBatch(options), delayBetweenBatches);
222
227
  }
223
228
 
224
229
  /**
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Settings controller — admin page for ActivityPub plugin configuration.
3
+ *
4
+ * GET: loads settings from ap_settings, renders form with defaults
5
+ * POST: validates, saves settings, redirects with success message
6
+ */
7
+ import { getSettings, saveSettings, DEFAULTS } from "../settings.js";
8
+ import { getToken, validateToken } from "../csrf.js";
9
+
10
+ export function settingsGetController(mountPath) {
11
+ return async (request, response, next) => {
12
+ try {
13
+ const { application } = request.app.locals;
14
+ const settings = await getSettings(application.collections);
15
+
16
+ response.render("activitypub-settings", {
17
+ title: response.locals.__("activitypub.settings.title"),
18
+ settings,
19
+ defaults: DEFAULTS,
20
+ mountPath,
21
+ saved: request.query.saved === "true",
22
+ csrfToken: getToken(request.session),
23
+ });
24
+ } catch (error) {
25
+ next(error);
26
+ }
27
+ };
28
+ }
29
+
30
+ export function settingsPostController(mountPath) {
31
+ return async (request, response, next) => {
32
+ try {
33
+ if (!validateToken(request)) {
34
+ return response.status(403).render("error", {
35
+ title: "Error",
36
+ content: "Invalid CSRF token",
37
+ });
38
+ }
39
+
40
+ const { application } = request.app.locals;
41
+ const body = request.body;
42
+
43
+ const settings = {
44
+ // Instance & Client API
45
+ instanceLanguages: (body.instanceLanguages || "en")
46
+ .split(",")
47
+ .map((s) => s.trim())
48
+ .filter(Boolean),
49
+ maxCharacters:
50
+ parseInt(body.maxCharacters, 10) || DEFAULTS.maxCharacters,
51
+ maxMediaAttachments:
52
+ parseInt(body.maxMediaAttachments, 10) || DEFAULTS.maxMediaAttachments,
53
+ defaultVisibility: body.defaultVisibility || DEFAULTS.defaultVisibility,
54
+ defaultLanguage: (body.defaultLanguage || DEFAULTS.defaultLanguage).trim(),
55
+
56
+ // Federation & Delivery
57
+ timelineRetention: parseInt(body.timelineRetention, 10) || 0,
58
+ notificationRetentionDays:
59
+ parseInt(body.notificationRetentionDays, 10) || 0,
60
+ activityRetentionDays:
61
+ parseInt(body.activityRetentionDays, 10) || 0,
62
+ replyChainDepth:
63
+ parseInt(body.replyChainDepth, 10) || DEFAULTS.replyChainDepth,
64
+ broadcastBatchSize:
65
+ parseInt(body.broadcastBatchSize, 10) || DEFAULTS.broadcastBatchSize,
66
+ broadcastBatchDelay:
67
+ parseInt(body.broadcastBatchDelay, 10) || DEFAULTS.broadcastBatchDelay,
68
+ parallelWorkers:
69
+ parseInt(body.parallelWorkers, 10) || DEFAULTS.parallelWorkers,
70
+ logLevel: body.logLevel || DEFAULTS.logLevel,
71
+
72
+ // Migration
73
+ refollowBatchSize:
74
+ parseInt(body.refollowBatchSize, 10) || DEFAULTS.refollowBatchSize,
75
+ refollowDelay:
76
+ parseInt(body.refollowDelay, 10) || DEFAULTS.refollowDelay,
77
+ refollowBatchDelay:
78
+ parseInt(body.refollowBatchDelay, 10) || DEFAULTS.refollowBatchDelay,
79
+
80
+ // Security
81
+ refreshTokenTtlDays:
82
+ parseInt(body.refreshTokenTtlDays, 10) || DEFAULTS.refreshTokenTtlDays,
83
+ };
84
+
85
+ await saveSettings(application.collections, settings);
86
+
87
+ response.redirect(`${mountPath}/admin/settings?saved=true`);
88
+ } catch (error) {
89
+ next(error);
90
+ }
91
+ };
92
+ }
@@ -38,6 +38,7 @@ import { addNotification } from "./storage/notifications.js";
38
38
  import { addMessage } from "./storage/messages.js";
39
39
  import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
40
40
  import { getFollowedTags } from "./storage/followed-tags.js";
41
+ import { getSettings } from "./settings.js";
41
42
 
42
43
  /** @type {string} ActivityStreams Public Collection constant */
43
44
  const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
@@ -760,7 +761,8 @@ export async function handleCreate(item, collections, ctx, handle) {
760
761
  // Each ancestor is stored with isContext: true to distinguish from organic timeline items.
761
762
  if (inReplyTo) {
762
763
  try {
763
- await fetchReplyChain(object, collections, authLoader, 5);
764
+ const settings = await getSettings(collections);
765
+ await fetchReplyChain(object, collections, authLoader, settings.replyChainDepth);
764
766
  } catch (error) {
765
767
  // Non-critical — incomplete context is acceptable
766
768
  console.warn("[inbox-handlers] Reply chain fetch failed:", error.message);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Keyword filter helpers for Mastodon Client API v2.
3
+ *
4
+ * Loads active filters from MongoDB and applies them to serialized
5
+ * Mastodon Status objects, following the v2 filter spec:
6
+ * - filterAction "hide" → status removed from results
7
+ * - filterAction "warn" → status kept with `filtered` array attached
8
+ */
9
+
10
+ /**
11
+ * Strip HTML tags from a string for plain-text keyword matching.
12
+ *
13
+ * @param {string} html - HTML string
14
+ * @returns {string} Plain text
15
+ */
16
+ function stripHtml(html) {
17
+ if (!html) return "";
18
+ return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
19
+ }
20
+
21
+ /**
22
+ * Compile a regex from a list of keyword documents.
23
+ *
24
+ * Keywords with `wholeWord: true` are wrapped in `\b` word boundaries.
25
+ * Keywords with `wholeWord: false` are matched as plain substrings.
26
+ * Returns null if there are no keywords.
27
+ *
28
+ * @param {Array<{keyword: string, wholeWord: boolean}>} keywords
29
+ * @returns {RegExp|null}
30
+ */
31
+ function compileKeywordRegex(keywords) {
32
+ if (!keywords || keywords.length === 0) return null;
33
+
34
+ const parts = keywords.map((kw) => {
35
+ const escaped = kw.keyword.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
36
+ return kw.wholeWord ? `\\b${escaped}\\b` : escaped;
37
+ });
38
+
39
+ return new RegExp(parts.join("|"), "i");
40
+ }
41
+
42
+ /**
43
+ * Load active filters for a given context from MongoDB.
44
+ *
45
+ * Skips expired filters. For each filter, loads its keywords and compiles
46
+ * a single regex from all of them.
47
+ *
48
+ * @param {object} collections - MongoDB collections (must have ap_filters, ap_filter_keywords)
49
+ * @param {string} context - Filter context to match ("home", "public", "notifications", "thread")
50
+ * @returns {Promise<Array<{id: string, title: string, context: string[], filterAction: string, expiresAt: string|null, regex: RegExp|null, keywords: Array}>>}
51
+ */
52
+ export async function loadUserFilters(collections, context) {
53
+ if (!collections.ap_filters) return [];
54
+
55
+ const now = new Date().toISOString();
56
+
57
+ // Load filters that include this context, skipping expired ones
58
+ const filterDocs = await collections.ap_filters
59
+ .find({ context })
60
+ .toArray();
61
+
62
+ const activeFilters = filterDocs.filter((f) => {
63
+ if (!f.expiresAt) return true;
64
+ return f.expiresAt > now;
65
+ });
66
+
67
+ if (activeFilters.length === 0) return [];
68
+
69
+ const result = [];
70
+
71
+ for (const filter of activeFilters) {
72
+ const keywords = collections.ap_filter_keywords
73
+ ? await collections.ap_filter_keywords
74
+ .find({ filterId: filter._id })
75
+ .toArray()
76
+ : [];
77
+
78
+ const regex = compileKeywordRegex(keywords);
79
+
80
+ result.push({
81
+ id: filter._id.toString(),
82
+ title: filter.title || "",
83
+ context: filter.context || [],
84
+ filterAction: filter.filterAction || "warn",
85
+ expiresAt: filter.expiresAt || null,
86
+ regex,
87
+ keywords,
88
+ });
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Apply compiled filters to an array of serialized Mastodon statuses.
96
+ *
97
+ * - "hide" filters: matching statuses are removed entirely
98
+ * - "warn" filters: matching statuses get a `filtered` array attached
99
+ *
100
+ * @param {Array<object>} statuses - Serialized Mastodon Status objects
101
+ * @param {Array<object>} filters - Compiled filter objects from loadUserFilters()
102
+ * @returns {Array<object>} Processed statuses (hide-matched ones removed)
103
+ */
104
+ export function applyFilters(statuses, filters) {
105
+ if (!filters || filters.length === 0) return statuses;
106
+
107
+ const result = [];
108
+
109
+ for (const status of statuses) {
110
+ const text = stripHtml(status.content || "");
111
+ let hidden = false;
112
+
113
+ for (const filter of filters) {
114
+ if (!filter.regex) continue;
115
+
116
+ const match = text.match(filter.regex);
117
+ if (!match) continue;
118
+
119
+ if (filter.filterAction === "hide") {
120
+ hidden = true;
121
+ break;
122
+ }
123
+
124
+ // filterAction === "warn" — attach filtered metadata
125
+ const matchedKeywords = filter.keywords
126
+ .filter((kw) => {
127
+ const escaped = kw.keyword.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
128
+ const kwRegex = new RegExp(
129
+ kw.wholeWord ? `\\b${escaped}\\b` : escaped,
130
+ "i",
131
+ );
132
+ return kwRegex.test(text);
133
+ })
134
+ .map((kw) => kw.keyword);
135
+
136
+ if (!status.filtered) {
137
+ status.filtered = [];
138
+ }
139
+
140
+ status.filtered.push({
141
+ filter: {
142
+ id: filter.id,
143
+ title: filter.title,
144
+ context: filter.context,
145
+ filter_action: filter.filterAction,
146
+ expires_at: filter.expiresAt,
147
+ },
148
+ keyword_matches: matchedKeywords,
149
+ });
150
+ }
151
+
152
+ if (!hidden) {
153
+ result.push(status);
154
+ }
155
+ }
156
+
157
+ return result;
158
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Settings cache middleware for Mastodon API hot paths.
3
+ *
4
+ * Loads settings once per minute (not per request) and attaches
5
+ * to req.app.locals.apSettings for all downstream handlers.
6
+ */
7
+ import { getSettings } from "../../settings.js";
8
+
9
+ let cachedSettings = null;
10
+ let cacheExpiry = 0;
11
+ const CACHE_TTL = 60_000; // 1 minute
12
+
13
+ export async function loadSettingsMiddleware(req, res, next) {
14
+ try {
15
+ const now = Date.now();
16
+ if (cachedSettings && now < cacheExpiry) {
17
+ req.app.locals.apSettings = cachedSettings;
18
+ return next();
19
+ }
20
+
21
+ const collections = req.app.locals.application?.collections;
22
+ cachedSettings = await getSettings(collections);
23
+ cacheExpiry = now + CACHE_TTL;
24
+ req.app.locals.apSettings = cachedSettings;
25
+ next();
26
+ } catch {
27
+ // On error, use defaults
28
+ if (!cachedSettings) {
29
+ const { DEFAULTS } = await import("../../settings.js");
30
+ cachedSettings = { ...DEFAULTS };
31
+ }
32
+ req.app.locals.apSettings = cachedSettings;
33
+ next();
34
+ }
35
+ }
@@ -8,6 +8,7 @@
8
8
  import express from "express";
9
9
  import rateLimit from "express-rate-limit";
10
10
  import { corsMiddleware } from "./middleware/cors.js";
11
+ import { loadSettingsMiddleware } from "./middleware/load-settings.js";
11
12
  import { tokenRequired, optionalToken } from "./middleware/token-required.js";
12
13
  import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
13
14
 
@@ -77,6 +78,10 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
77
78
  // ─── CORS ───────────────────────────────────────────────────────────────
78
79
  router.use("/api", corsMiddleware);
79
80
  router.use("/oauth/token", corsMiddleware);
81
+
82
+ // ─── Settings cache ────────────────────────────────────────────────────
83
+ // Loads plugin settings once per minute, available as req.app.locals.apSettings
84
+ router.use("/api", loadSettingsMiddleware);
80
85
  router.use("/oauth/revoke", corsMiddleware);
81
86
  router.use("/.well-known/oauth-authorization-server", corsMiddleware);
82
87
 
@@ -60,10 +60,11 @@ router.get("/api/v1/accounts/verify_credentials", tokenRequired, scopeRequired("
60
60
  // ─── GET /api/v1/preferences ─────────────────────────────────────────────────
61
61
 
62
62
  router.get("/api/v1/preferences", tokenRequired, scopeRequired("read", "read:accounts"), (req, res) => {
63
+ const apSettings = req.app.locals.apSettings;
63
64
  res.json({
64
- "posting:default:visibility": "public",
65
+ "posting:default:visibility": apSettings?.defaultVisibility || "public",
65
66
  "posting:default:sensitive": false,
66
- "posting:default:language": "en",
67
+ "posting:default:language": apSettings?.defaultLanguage || "en",
67
68
  "reading:expand:media": "default",
68
69
  "reading:expand:spoilers": false,
69
70
  });
@@ -17,6 +17,7 @@ router.get("/api/v2/instance", async (req, res, next) => {
17
17
  const domain = req.get("host");
18
18
  const collections = req.app.locals.mastodonCollections;
19
19
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
20
+ const apSettings = req.app.locals.apSettings;
20
21
 
21
22
  const profile = await collections.ap_profile.findOne({});
22
23
  const contactAccount = profile
@@ -44,7 +45,7 @@ router.get("/api/v2/instance", async (req, res, next) => {
44
45
  versions: {},
45
46
  },
46
47
  icon: [],
47
- languages: ["en"],
48
+ languages: apSettings?.instanceLanguages || ["en"],
48
49
  configuration: {
49
50
  urls: {
50
51
  streaming: "",
@@ -54,8 +55,8 @@ router.get("/api/v2/instance", async (req, res, next) => {
54
55
  max_pinned_statuses: 10,
55
56
  },
56
57
  statuses: {
57
- max_characters: 5000,
58
- max_media_attachments: 4,
58
+ max_characters: apSettings?.maxCharacters || 5000,
59
+ max_media_attachments: apSettings?.maxMediaAttachments || 4,
59
60
  characters_reserved_per_url: 23,
60
61
  },
61
62
  media_attachments: {
@@ -116,6 +117,7 @@ router.get("/api/v1/instance", async (req, res, next) => {
116
117
  const domain = req.get("host");
117
118
  const collections = req.app.locals.mastodonCollections;
118
119
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
120
+ const apSettings = req.app.locals.apSettings;
119
121
 
120
122
  const profile = await collections.ap_profile.findOne({});
121
123
 
@@ -160,14 +162,14 @@ router.get("/api/v1/instance", async (req, res, next) => {
160
162
  domain_count: domainCount,
161
163
  },
162
164
  thumbnail: profile?.icon || null,
163
- languages: ["en"],
165
+ languages: apSettings?.instanceLanguages || ["en"],
164
166
  registrations: false,
165
167
  approval_required: true,
166
168
  invites_enabled: false,
167
169
  configuration: {
168
170
  statuses: {
169
- max_characters: 5000,
170
- max_media_attachments: 4,
171
+ max_characters: apSettings?.maxCharacters || 5000,
172
+ max_media_attachments: apSettings?.maxMediaAttachments || 4,
171
173
  characters_reserved_per_url: 23,
172
174
  },
173
175
  media_attachments: {
@@ -502,13 +502,15 @@ router.post("/oauth/token", async (req, res, next) => {
502
502
  // Rotate: new access token + new refresh token
503
503
  const newAccessToken = randomHex(64);
504
504
  const newRefreshToken = randomHex(64);
505
+ const refreshTtlDaysRotate = req.app.locals.apSettings?.refreshTokenTtlDays || 90;
506
+ const refreshTtlMsRotate = refreshTtlDaysRotate * 24 * 3600 * 1000;
505
507
  await collections.ap_oauth_tokens.updateOne(
506
508
  { _id: existing._id },
507
509
  {
508
510
  $set: {
509
511
  accessToken: newAccessToken,
510
512
  refreshToken: newRefreshToken,
511
- refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000),
513
+ refreshExpiresAt: new Date(Date.now() + refreshTtlMsRotate),
512
514
  },
513
515
  $unset: { expiresAt: "" },
514
516
  },
@@ -589,8 +591,9 @@ router.post("/oauth/token", async (req, res, next) => {
589
591
 
590
592
  // Generate access token and refresh token.
591
593
  // Access tokens do not expire (matching Mastodon behavior — valid until revoked).
592
- // Refresh tokens expire after 90 days as a safety measure.
593
- const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days
594
+ // Refresh tokens expire after a configurable number of days (default 90).
595
+ const refreshTtlDays = req.app.locals.apSettings?.refreshTokenTtlDays || 90;
596
+ const REFRESH_TOKEN_TTL = refreshTtlDays * 24 * 3600 * 1000;
594
597
  const accessToken = randomHex(64);
595
598
  const refreshToken = randomHex(64);
596
599
  await collections.ap_oauth_tokens.updateOne(
@@ -11,6 +11,7 @@ import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpe
11
11
  import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
12
12
  import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
13
13
  import { enrichAccountStats } from "../helpers/enrich-accounts.js";
14
+ import { loadUserFilters, applyFilters } from "../helpers/apply-filters.js";
14
15
  import { tokenRequired } from "../middleware/token-required.js";
15
16
  import { scopeRequired } from "../middleware/scope-required.js";
16
17
 
@@ -85,10 +86,17 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
85
86
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
86
87
  await enrichAccountStats(statuses, pluginOptions, baseUrl);
87
88
 
89
+ // Apply keyword filters
90
+ let filteredStatuses = statuses;
91
+ if (collections.ap_filters) {
92
+ const filters = await loadUserFilters(collections, "home");
93
+ filteredStatuses = applyFilters(statuses, filters);
94
+ }
95
+
88
96
  // Set pagination Link headers
89
97
  setPaginationHeaders(res, req, items, limit);
90
98
 
91
- res.json(statuses);
99
+ res.json(filteredStatuses);
92
100
  } catch (error) {
93
101
  next(error);
94
102
  }
@@ -102,9 +110,10 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
102
110
  const baseUrl = `${req.protocol}://${req.get("host")}`;
103
111
  const limit = parseLimit(req.query.limit);
104
112
 
105
- // Public timeline: only public visibility, no context items
113
+ // Public timeline: only public visibility, no context items, no replies
106
114
  const baseFilter = {
107
115
  isContext: { $ne: true },
116
+ inReplyTo: { $exists: false },
108
117
  visibility: "public",
109
118
  };
110
119
 
@@ -186,8 +195,15 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
186
195
  const pluginOpts = req.app.locals.mastodonPluginOptions || {};
187
196
  await enrichAccountStats(statuses, pluginOpts, baseUrl);
188
197
 
198
+ // Apply keyword filters
199
+ let filteredStatuses = statuses;
200
+ if (collections.ap_filters) {
201
+ const filters = await loadUserFilters(collections, "public");
202
+ filteredStatuses = applyFilters(statuses, filters);
203
+ }
204
+
189
205
  setPaginationHeaders(res, req, items, limit);
190
- res.json(statuses);
206
+ res.json(filteredStatuses);
191
207
  } catch (error) {
192
208
  next(error);
193
209
  }
@@ -204,6 +220,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
204
220
 
205
221
  const baseFilter = {
206
222
  isContext: { $ne: true },
223
+ inReplyTo: { $exists: false },
207
224
  visibility: { $in: ["public", "unlisted"] },
208
225
  category: hashtag,
209
226
  };
@@ -253,8 +270,15 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
253
270
  const pluginOpts = req.app.locals.mastodonPluginOptions || {};
254
271
  await enrichAccountStats(statuses, pluginOpts, baseUrl);
255
272
 
273
+ // Apply keyword filters
274
+ let filteredStatuses = statuses;
275
+ if (collections.ap_filters) {
276
+ const filters = await loadUserFilters(collections, "public");
277
+ filteredStatuses = applyFilters(statuses, filters);
278
+ }
279
+
256
280
  setPaginationHeaders(res, req, items, limit);
257
- res.json(statuses);
281
+ res.json(filteredStatuses);
258
282
  } catch (error) {
259
283
  next(error);
260
284
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Plugin settings — stored in ap_settings MongoDB collection.
3
+ *
4
+ * getSettings() merges DB values over hardcoded defaults.
5
+ * Consumers call this once per operation (or use cached middleware for hot paths).
6
+ */
7
+
8
+ export const DEFAULTS = {
9
+ // Instance & Client API
10
+ instanceLanguages: ["en"],
11
+ maxCharacters: 5000,
12
+ maxMediaAttachments: 4,
13
+ defaultVisibility: "public",
14
+ defaultLanguage: "en",
15
+
16
+ // Federation & Delivery
17
+ timelineRetention: 1000,
18
+ notificationRetentionDays: 30,
19
+ activityRetentionDays: 90,
20
+ replyChainDepth: 5,
21
+ broadcastBatchSize: 25,
22
+ broadcastBatchDelay: 5000,
23
+ parallelWorkers: 5,
24
+ logLevel: "warning",
25
+
26
+ // Migration
27
+ refollowBatchSize: 10,
28
+ refollowDelay: 3000,
29
+ refollowBatchDelay: 30000,
30
+
31
+ // Security
32
+ refreshTokenTtlDays: 90,
33
+ };
34
+
35
+ /**
36
+ * Load settings from MongoDB, merged over defaults.
37
+ *
38
+ * @param {Map|object} collections - Indiekit collections map or plain object with ap_settings
39
+ * @returns {Promise<object>} Settings object with all keys guaranteed present
40
+ */
41
+ export async function getSettings(collections) {
42
+ const col = collections?.get
43
+ ? collections.get("ap_settings")
44
+ : collections?.ap_settings;
45
+ if (!col) return { ...DEFAULTS };
46
+
47
+ try {
48
+ const doc = await col.findOne({});
49
+ return { ...DEFAULTS, ...(doc?.settings || {}) };
50
+ } catch {
51
+ return { ...DEFAULTS };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Save settings to MongoDB.
57
+ *
58
+ * @param {Map|object} collections - Indiekit collections map or plain object
59
+ * @param {object} settings - Settings object (all keys from DEFAULTS)
60
+ */
61
+ export async function saveSettings(collections, settings) {
62
+ const col = collections?.get
63
+ ? collections.get("ap_settings")
64
+ : collections?.ap_settings;
65
+ if (!col) return;
66
+
67
+ await col.updateOne(
68
+ {},
69
+ { $set: { settings, updatedAt: new Date().toISOString() } },
70
+ { upsert: true },
71
+ );
72
+ }
package/locales/en.json CHANGED
@@ -333,6 +333,9 @@
333
333
  "deleteSuccess": "Delete activity sent to followers",
334
334
  "deleteButton": "Delete from fediverse"
335
335
  },
336
+ "settings": {
337
+ "title": "Settings"
338
+ },
336
339
  "federationMgmt": {
337
340
  "title": "Federation",
338
341
  "collections": "Collection health",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.12.4",
3
+ "version": "3.13.0",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -0,0 +1,187 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "input/macro.njk" import input with context %}
5
+ {% from "radios/macro.njk" import radios with context %}
6
+ {% from "button/macro.njk" import button with context %}
7
+ {% from "notification-banner/macro.njk" import notificationBanner with context %}
8
+
9
+ {% block content %}
10
+ {% if saved %}
11
+ {{ notificationBanner({ type: "success", text: "Settings saved." }) }}
12
+ {% endif %}
13
+
14
+ {{ heading({ text: title }) }}
15
+
16
+ <form method="POST" action="{{ mountPath }}/admin/settings">
17
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
18
+
19
+ {# ─── Instance & Client API ──────────────────────────────────── #}
20
+ <fieldset class="fieldset">
21
+ <legend class="fieldset__legend">Instance &amp; Client API</legend>
22
+ <p class="hint">Settings reported to Mastodon-compatible clients (Phanpy, Elk, Moshidon).</p>
23
+
24
+ {{ input({
25
+ name: "instanceLanguages",
26
+ label: "Instance languages",
27
+ hint: "Comma-separated ISO 639-1 codes (e.g. en,fr,de). Default: " + defaults.instanceLanguages | join(","),
28
+ value: settings.instanceLanguages | join(","),
29
+ type: "text"
30
+ }) }}
31
+
32
+ {{ input({
33
+ name: "maxCharacters",
34
+ label: "Max characters per status",
35
+ hint: "Character limit shown to clients. Default: " + defaults.maxCharacters,
36
+ value: settings.maxCharacters,
37
+ type: "number"
38
+ }) }}
39
+
40
+ {{ input({
41
+ name: "maxMediaAttachments",
42
+ label: "Max media attachments",
43
+ hint: "Per-status media file limit. Default: " + defaults.maxMediaAttachments,
44
+ value: settings.maxMediaAttachments,
45
+ type: "number"
46
+ }) }}
47
+
48
+ {{ radios({
49
+ name: "defaultVisibility",
50
+ label: "Default post visibility",
51
+ hint: "Default visibility for new posts. Default: " + defaults.defaultVisibility,
52
+ items: [
53
+ { value: "public", text: "Public", checked: settings.defaultVisibility == "public" },
54
+ { value: "unlisted", text: "Unlisted", checked: settings.defaultVisibility == "unlisted" },
55
+ { value: "private", text: "Followers only", checked: settings.defaultVisibility == "private" }
56
+ ]
57
+ }) }}
58
+
59
+ {{ input({
60
+ name: "defaultLanguage",
61
+ label: "Default post language",
62
+ hint: "ISO 639-1 code (e.g. en, fr). Default: " + defaults.defaultLanguage,
63
+ value: settings.defaultLanguage,
64
+ type: "text"
65
+ }) }}
66
+ </fieldset>
67
+
68
+ {# ─── Federation & Delivery ──────────────────────────────────── #}
69
+ <fieldset class="fieldset">
70
+ <legend class="fieldset__legend">Federation &amp; Delivery</legend>
71
+ <p class="hint">Controls how content is stored, retained, and delivered to followers.</p>
72
+
73
+ {{ input({
74
+ name: "timelineRetention",
75
+ label: "Timeline retention (items)",
76
+ hint: "Max items in the AP timeline. 0 = unlimited. Default: " + defaults.timelineRetention,
77
+ value: settings.timelineRetention,
78
+ type: "number"
79
+ }) }}
80
+
81
+ {{ input({
82
+ name: "notificationRetentionDays",
83
+ label: "Notification retention (days)",
84
+ hint: "Days to keep notifications. 0 = forever. Default: " + defaults.notificationRetentionDays,
85
+ value: settings.notificationRetentionDays,
86
+ type: "number"
87
+ }) }}
88
+
89
+ {{ input({
90
+ name: "activityRetentionDays",
91
+ label: "Activity log retention (days)",
92
+ hint: "Days to keep activity log entries. 0 = forever. Default: " + defaults.activityRetentionDays,
93
+ value: settings.activityRetentionDays,
94
+ type: "number"
95
+ }) }}
96
+
97
+ {{ input({
98
+ name: "replyChainDepth",
99
+ label: "Reply chain depth",
100
+ hint: "Max parent posts fetched for thread context. Default: " + defaults.replyChainDepth,
101
+ value: settings.replyChainDepth,
102
+ type: "number"
103
+ }) }}
104
+
105
+ {{ input({
106
+ name: "broadcastBatchSize",
107
+ label: "Broadcast batch size",
108
+ hint: "Followers per delivery batch. Default: " + defaults.broadcastBatchSize,
109
+ value: settings.broadcastBatchSize,
110
+ type: "number"
111
+ }) }}
112
+
113
+ {{ input({
114
+ name: "broadcastBatchDelay",
115
+ label: "Broadcast batch delay (ms)",
116
+ hint: "Delay between delivery batches in milliseconds. Default: " + defaults.broadcastBatchDelay,
117
+ value: settings.broadcastBatchDelay,
118
+ type: "number"
119
+ }) }}
120
+
121
+ {{ input({
122
+ name: "parallelWorkers",
123
+ label: "Parallel delivery workers",
124
+ hint: "Redis queue workers. 0 = in-process queue. Default: " + defaults.parallelWorkers,
125
+ value: settings.parallelWorkers,
126
+ type: "number"
127
+ }) }}
128
+
129
+ {{ radios({
130
+ name: "logLevel",
131
+ label: "Federation log level",
132
+ hint: "Fedify log verbosity. Default: " + defaults.logLevel,
133
+ items: [
134
+ { value: "debug", text: "Debug", checked: settings.logLevel == "debug" },
135
+ { value: "info", text: "Info", checked: settings.logLevel == "info" },
136
+ { value: "warning", text: "Warning", checked: settings.logLevel == "warning" },
137
+ { value: "error", text: "Error", checked: settings.logLevel == "error" }
138
+ ]
139
+ }) }}
140
+ </fieldset>
141
+
142
+ {# ─── Migration ──────────────────────────────────────────────── #}
143
+ <fieldset class="fieldset">
144
+ <legend class="fieldset__legend">Migration</legend>
145
+ <p class="hint">Controls the speed of Mastodon account re-follow processing.</p>
146
+
147
+ {{ input({
148
+ name: "refollowBatchSize",
149
+ label: "Refollow batch size",
150
+ hint: "Accounts per refollow batch. Default: " + defaults.refollowBatchSize,
151
+ value: settings.refollowBatchSize,
152
+ type: "number"
153
+ }) }}
154
+
155
+ {{ input({
156
+ name: "refollowDelay",
157
+ label: "Refollow delay per follow (ms)",
158
+ hint: "Delay between individual follow requests. Default: " + defaults.refollowDelay,
159
+ value: settings.refollowDelay,
160
+ type: "number"
161
+ }) }}
162
+
163
+ {{ input({
164
+ name: "refollowBatchDelay",
165
+ label: "Refollow batch delay (ms)",
166
+ hint: "Delay between refollow batches. Default: " + defaults.refollowBatchDelay,
167
+ value: settings.refollowBatchDelay,
168
+ type: "number"
169
+ }) }}
170
+ </fieldset>
171
+
172
+ {# ─── Security ───────────────────────────────────────────────── #}
173
+ <fieldset class="fieldset">
174
+ <legend class="fieldset__legend">Security</legend>
175
+
176
+ {{ input({
177
+ name: "refreshTokenTtlDays",
178
+ label: "Refresh token TTL (days)",
179
+ hint: "Days before OAuth refresh tokens expire. Access tokens never expire. Default: " + defaults.refreshTokenTtlDays,
180
+ value: settings.refreshTokenTtlDays,
181
+ type: "number"
182
+ }) }}
183
+ </fieldset>
184
+
185
+ {{ button({ text: "Save settings" }) }}
186
+ </form>
187
+ {% endblock %}