@rmdes/indiekit-endpoint-activitypub 3.12.5 → 3.13.1

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,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(
@@ -453,7 +453,9 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
453
453
  : new URL(application.micropubEndpoint, application.url).href;
454
454
 
455
455
  const token =
456
- req.session?.access_token || req.mastodonToken?.accessToken;
456
+ req.session?.access_token ||
457
+ req.mastodonToken?.indieauthToken ||
458
+ req.mastodonToken?.accessToken;
457
459
  if (token) {
458
460
  const updatePayload = {
459
461
  action: "update",
@@ -513,13 +515,16 @@ router.put("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:
513
515
  const updated = await collections.ap_timeline.findOne({
514
516
  _id: item._id,
515
517
  });
516
- const { serializeStatus, setLocalIdentity } = await import(
517
- "../entities/status.js"
518
- );
519
- const handle = pluginOptions.actor?.handle || "";
520
- setLocalIdentity(localPublicationUrl, handle);
518
+ const interactionState = await loadItemInteractions(collections, updated);
519
+ const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [updated]);
521
520
 
522
- const serialized = serializeStatus(updated, { baseUrl });
521
+ const serialized = serializeStatus(updated, {
522
+ baseUrl,
523
+ ...interactionState,
524
+ pinnedIds: new Set(),
525
+ replyIdMap,
526
+ replyAccountIdMap,
527
+ });
523
528
  res.json(serialized);
524
529
  } catch (error) {
525
530
  next(error);
@@ -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.5",
3
+ "version": "3.13.1",
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 %}