@rmdes/indiekit-endpoint-activitypub 3.8.7 → 3.9.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.
Files changed (52) hide show
  1. package/assets/css/base.css +144 -0
  2. package/assets/css/card.css +377 -0
  3. package/assets/css/compose.css +169 -0
  4. package/assets/css/dark-mode.css +94 -0
  5. package/assets/css/explore.css +530 -0
  6. package/assets/css/features.css +436 -0
  7. package/assets/css/federation.css +242 -0
  8. package/assets/css/interactions.css +236 -0
  9. package/assets/css/media.css +315 -0
  10. package/assets/css/messages.css +158 -0
  11. package/assets/css/moderation.css +119 -0
  12. package/assets/css/notifications.css +191 -0
  13. package/assets/css/profile.css +308 -0
  14. package/assets/css/responsive.css +33 -0
  15. package/assets/css/skeleton.css +74 -0
  16. package/assets/reader-interactions.js +115 -0
  17. package/assets/reader.css +20 -3439
  18. package/index.js +34 -694
  19. package/lib/batch-broadcast.js +98 -0
  20. package/lib/controllers/compose.js +5 -7
  21. package/lib/controllers/interactions-boost.js +8 -13
  22. package/lib/controllers/interactions-like.js +8 -13
  23. package/lib/federation-actions.js +70 -0
  24. package/lib/inbox-queue.js +13 -6
  25. package/lib/init-indexes.js +251 -0
  26. package/lib/item-processing.js +22 -2
  27. package/lib/lookup-cache.js +3 -0
  28. package/lib/mastodon/backfill-timeline.js +11 -2
  29. package/lib/mastodon/entities/sanitize.js +19 -88
  30. package/lib/mastodon/helpers/account-cache.js +3 -0
  31. package/lib/mastodon/helpers/enrich-accounts.js +42 -55
  32. package/lib/mastodon/router.js +31 -0
  33. package/lib/mastodon/routes/accounts.js +16 -49
  34. package/lib/mastodon/routes/media.js +6 -4
  35. package/lib/mastodon/routes/notifications.js +6 -24
  36. package/lib/mastodon/routes/oauth.js +91 -18
  37. package/lib/mastodon/routes/search.js +3 -1
  38. package/lib/mastodon/routes/statuses.js +14 -52
  39. package/lib/mastodon/routes/timelines.js +3 -6
  40. package/lib/og-unfurl.js +52 -33
  41. package/lib/storage/moderation.js +11 -2
  42. package/lib/syndicator.js +239 -0
  43. package/lib/timeline-store.js +11 -15
  44. package/package.json +2 -1
  45. package/views/activitypub-federation-mgmt.njk +2 -2
  46. package/views/activitypub-moderation.njk +1 -1
  47. package/views/activitypub-profile.njk +16 -76
  48. package/views/activitypub-reader.njk +2 -1
  49. package/views/layouts/ap-reader.njk +2 -0
  50. package/views/partials/ap-item-card.njk +14 -117
  51. package/views/partials/ap-item-content.njk +20 -0
  52. package/views/partials/ap-notification-card.njk +1 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared batch broadcast for delivering activities to all followers.
3
+ * Deduplicates by shared inbox and delivers in batches with delay.
4
+ * @module batch-broadcast
5
+ */
6
+ import { logActivity } from "./activity-log.js";
7
+
8
+ const BATCH_SIZE = 25;
9
+ const BATCH_DELAY_MS = 5000;
10
+
11
+ /**
12
+ * Broadcast an activity to all followers via batch delivery.
13
+ *
14
+ * @param {object} options
15
+ * @param {object} options.federation - Fedify Federation instance
16
+ * @param {object} options.collections - MongoDB collections (needs ap_followers, ap_activities)
17
+ * @param {string} options.publicationUrl - Our publication URL
18
+ * @param {string} options.handle - Our actor handle
19
+ * @param {object} options.activity - Fedify activity object to send
20
+ * @param {string} options.label - Human-readable label for logging (e.g. "Update(Person)")
21
+ * @param {string} [options.objectUrl] - URL of the object being broadcast about
22
+ */
23
+ export async function batchBroadcast({
24
+ federation,
25
+ collections,
26
+ publicationUrl,
27
+ handle,
28
+ activity,
29
+ label,
30
+ objectUrl,
31
+ }) {
32
+ const ctx = federation.createContext(new URL(publicationUrl), {
33
+ handle,
34
+ publicationUrl,
35
+ });
36
+
37
+ const followers = await collections.ap_followers
38
+ .find({})
39
+ .project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
40
+ .toArray();
41
+
42
+ // Deduplicate by shared inbox
43
+ const inboxMap = new Map();
44
+ for (const f of followers) {
45
+ const key = f.sharedInbox || f.inbox;
46
+ if (key && !inboxMap.has(key)) {
47
+ inboxMap.set(key, f);
48
+ }
49
+ }
50
+
51
+ const uniqueRecipients = [...inboxMap.values()];
52
+ let delivered = 0;
53
+ let failed = 0;
54
+
55
+ console.info(
56
+ `[ActivityPub] Broadcasting ${label} to ${uniqueRecipients.length} ` +
57
+ `unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
58
+ );
59
+
60
+ for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
61
+ const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
62
+ const recipients = batch.map((f) => ({
63
+ id: new URL(f.actorUrl),
64
+ inboxId: new URL(f.inbox || f.sharedInbox),
65
+ endpoints: f.sharedInbox
66
+ ? { sharedInbox: new URL(f.sharedInbox) }
67
+ : undefined,
68
+ }));
69
+
70
+ try {
71
+ await ctx.sendActivity({ identifier: handle }, recipients, activity, {
72
+ preferSharedInbox: true,
73
+ });
74
+ delivered += batch.length;
75
+ } catch (error) {
76
+ failed += batch.length;
77
+ console.warn(
78
+ `[ActivityPub] ${label} batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
79
+ );
80
+ }
81
+
82
+ if (i + BATCH_SIZE < uniqueRecipients.length) {
83
+ await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
84
+ }
85
+ }
86
+
87
+ console.info(
88
+ `[ActivityPub] ${label} broadcast complete: ${delivered} delivered, ${failed} failed`,
89
+ );
90
+
91
+ await logActivity(collections.ap_activities, {
92
+ direction: "outbound",
93
+ type: label.includes("(") ? label.split("(")[0] : label,
94
+ actorUrl: publicationUrl,
95
+ objectUrl: objectUrl || "",
96
+ summary: `Sent ${label} to ${delivered}/${uniqueRecipients.length} inboxes`,
97
+ }).catch(() => {});
98
+ }
@@ -5,6 +5,7 @@
5
5
  import { getToken, validateToken } from "../csrf.js";
6
6
  import { sanitizeContent } from "../timeline-store.js";
7
7
  import { lookupWithSecurity } from "../lookup-helpers.js";
8
+ import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
8
9
 
9
10
  /**
10
11
  * Fetch syndication targets from the Micropub config endpoint.
@@ -69,18 +70,15 @@ export function composeController(mountPath, plugin) {
69
70
  : null;
70
71
 
71
72
  // If not in timeline, try to look up remotely
72
- if (!replyContext && plugin._federation) {
73
+ if (!replyContext && isFederationReady(plugin)) {
73
74
  try {
74
- const handle = plugin.options.actor.handle;
75
- const ctx = plugin._federation.createContext(
76
- new URL(plugin._publicationUrl),
77
- { handle, publicationUrl: plugin._publicationUrl },
78
- );
75
+ const handle = getHandle(plugin);
76
+ const ctx = createContext(plugin);
79
77
  // Use authenticated document loader for Authorized Fetch
80
78
  const documentLoader = await ctx.getDocumentLoader({
81
79
  identifier: handle,
82
80
  });
83
- const remoteObject = await lookupWithSecurity(ctx,new URL(replyTo), {
81
+ const remoteObject = await lookupWithSecurity(ctx, new URL(replyTo), {
84
82
  documentLoader,
85
83
  });
86
84
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { validateToken } from "../csrf.js";
7
7
  import { resolveAuthor } from "../resolve-author.js";
8
+ import { createContext, getHandle, getPublicationUrl, isFederationReady } from "../federation-actions.js";
8
9
 
9
10
  /**
10
11
  * POST /admin/reader/boost — send an Announce activity to followers.
@@ -28,7 +29,7 @@ export function boostController(mountPath, plugin) {
28
29
  });
29
30
  }
30
31
 
31
- if (!plugin._federation) {
32
+ if (!isFederationReady(plugin)) {
32
33
  return response.status(503).json({
33
34
  success: false,
34
35
  error: "Federation not initialized",
@@ -36,14 +37,11 @@ export function boostController(mountPath, plugin) {
36
37
  }
37
38
 
38
39
  const { Announce } = await import("@fedify/fedify/vocab");
39
- const handle = plugin.options.actor.handle;
40
- const ctx = plugin._federation.createContext(
41
- new URL(plugin._publicationUrl),
42
- { handle, publicationUrl: plugin._publicationUrl },
43
- );
40
+ const handle = getHandle(plugin);
41
+ const ctx = createContext(plugin);
44
42
 
45
43
  const uuid = crypto.randomUUID();
46
- const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
44
+ const baseUrl = getPublicationUrl(plugin).replace(/\/$/, "");
47
45
  const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
48
46
 
49
47
  const publicAddress = new URL(
@@ -160,7 +158,7 @@ export function unboostController(mountPath, plugin) {
160
158
  });
161
159
  }
162
160
 
163
- if (!plugin._federation) {
161
+ if (!isFederationReady(plugin)) {
164
162
  return response.status(503).json({
165
163
  success: false,
166
164
  error: "Federation not initialized",
@@ -182,11 +180,8 @@ export function unboostController(mountPath, plugin) {
182
180
  }
183
181
 
184
182
  const { Announce, Undo } = await import("@fedify/fedify/vocab");
185
- const handle = plugin.options.actor.handle;
186
- const ctx = plugin._federation.createContext(
187
- new URL(plugin._publicationUrl),
188
- { handle, publicationUrl: plugin._publicationUrl },
189
- );
183
+ const handle = getHandle(plugin);
184
+ const ctx = createContext(plugin);
190
185
 
191
186
  // Construct Undo(Announce)
192
187
  const announce = new Announce({
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { validateToken } from "../csrf.js";
7
7
  import { resolveAuthor } from "../resolve-author.js";
8
+ import { createContext, getHandle, getPublicationUrl, isFederationReady } from "../federation-actions.js";
8
9
 
9
10
  /**
10
11
  * POST /admin/reader/like — send a Like activity to the post author.
@@ -30,7 +31,7 @@ export function likeController(mountPath, plugin) {
30
31
  });
31
32
  }
32
33
 
33
- if (!plugin._federation) {
34
+ if (!isFederationReady(plugin)) {
34
35
  return response.status(503).json({
35
36
  success: false,
36
37
  error: "Federation not initialized",
@@ -38,11 +39,8 @@ export function likeController(mountPath, plugin) {
38
39
  }
39
40
 
40
41
  const { Like } = await import("@fedify/fedify/vocab");
41
- const handle = plugin.options.actor.handle;
42
- const ctx = plugin._federation.createContext(
43
- new URL(plugin._publicationUrl),
44
- { handle, publicationUrl: plugin._publicationUrl },
45
- );
42
+ const handle = getHandle(plugin);
43
+ const ctx = createContext(plugin);
46
44
 
47
45
  const documentLoader = await ctx.getDocumentLoader({
48
46
  identifier: handle,
@@ -70,7 +68,7 @@ export function likeController(mountPath, plugin) {
70
68
 
71
69
  // Generate a unique activity ID
72
70
  const uuid = crypto.randomUUID();
73
- const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
71
+ const baseUrl = getPublicationUrl(plugin).replace(/\/$/, "");
74
72
  const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
75
73
 
76
74
  // Construct and send Like activity
@@ -142,7 +140,7 @@ export function unlikeController(mountPath, plugin) {
142
140
  });
143
141
  }
144
142
 
145
- if (!plugin._federation) {
143
+ if (!isFederationReady(plugin)) {
146
144
  return response.status(503).json({
147
145
  success: false,
148
146
  error: "Federation not initialized",
@@ -165,11 +163,8 @@ export function unlikeController(mountPath, plugin) {
165
163
  }
166
164
 
167
165
  const { Like, Undo } = await import("@fedify/fedify/vocab");
168
- const handle = plugin.options.actor.handle;
169
- const ctx = plugin._federation.createContext(
170
- new URL(plugin._publicationUrl),
171
- { handle, publicationUrl: plugin._publicationUrl },
172
- );
166
+ const handle = getHandle(plugin);
167
+ const ctx = createContext(plugin);
173
168
 
174
169
  const documentLoader = await ctx.getDocumentLoader({
175
170
  identifier: handle,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Facade for federation operations used by controllers.
3
+ * Centralizes Fedify context creation and common patterns
4
+ * so controllers don't access plugin._federation directly.
5
+ * @module federation-actions
6
+ */
7
+ import { lookupWithSecurity } from "./lookup-helpers.js";
8
+
9
+ /**
10
+ * Create a Fedify context from the plugin reference.
11
+ * @param {object} plugin - ActivityPubEndpoint instance
12
+ * @returns {object} Fedify Context
13
+ */
14
+ export function createContext(plugin) {
15
+ const handle = plugin.options.actor.handle;
16
+ return plugin._federation.createContext(new URL(plugin._publicationUrl), {
17
+ handle,
18
+ publicationUrl: plugin._publicationUrl,
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Get an authenticated document loader for signed HTTP fetches.
24
+ * @param {object} plugin - ActivityPubEndpoint instance
25
+ * @returns {Promise<object>} Fedify DocumentLoader
26
+ */
27
+ export async function getAuthLoader(plugin) {
28
+ const ctx = createContext(plugin);
29
+ return ctx.getDocumentLoader({ identifier: plugin.options.actor.handle });
30
+ }
31
+
32
+ /**
33
+ * Resolve a remote actor with signed→unsigned fallback.
34
+ * @param {object} plugin - ActivityPubEndpoint instance
35
+ * @param {string|URL} target - Actor URL or acct: URI
36
+ * @param {object} [options] - Additional options for lookupWithSecurity
37
+ * @returns {Promise<object|null>} Resolved actor or null
38
+ */
39
+ export async function resolveActor(plugin, target, options = {}) {
40
+ const ctx = createContext(plugin);
41
+ const documentLoader = await ctx.getDocumentLoader({
42
+ identifier: plugin.options.actor.handle,
43
+ });
44
+ const url = target instanceof URL ? target : new URL(target);
45
+ return lookupWithSecurity(ctx, url, { documentLoader, ...options });
46
+ }
47
+
48
+ /**
49
+ * Check if federation is initialized and ready.
50
+ * @param {object} plugin - ActivityPubEndpoint instance
51
+ * @returns {boolean}
52
+ */
53
+ export function isFederationReady(plugin) {
54
+ return !!plugin._federation;
55
+ }
56
+
57
+ /** @returns {string} Our actor handle */
58
+ export function getHandle(plugin) {
59
+ return plugin.options.actor.handle;
60
+ }
61
+
62
+ /** @returns {string} Our publication URL */
63
+ export function getPublicationUrl(plugin) {
64
+ return plugin._publicationUrl;
65
+ }
66
+
67
+ /** @returns {object} MongoDB collections */
68
+ export function getCollections(plugin) {
69
+ return plugin._collections;
70
+ }
@@ -17,14 +17,14 @@ import { routeToHandler } from "./inbox-handlers.js";
17
17
  */
18
18
  async function processNextItem(collections, ctx, handle) {
19
19
  const { ap_inbox_queue } = collections;
20
- if (!ap_inbox_queue) return;
20
+ if (!ap_inbox_queue) return false;
21
21
 
22
22
  const item = await ap_inbox_queue.findOneAndUpdate(
23
23
  { status: "pending" },
24
24
  { $set: { status: "processing" } },
25
25
  { sort: { receivedAt: 1 }, returnDocument: "after" },
26
26
  );
27
- if (!item) return;
27
+ if (!item) return false;
28
28
 
29
29
  try {
30
30
  await routeToHandler(item, collections, ctx, handle);
@@ -45,6 +45,8 @@ async function processNextItem(collections, ctx, handle) {
45
45
  );
46
46
  console.error(`[inbox-queue] Failed processing ${item.activityType} from ${item.actorUrl}: ${error.message}`);
47
47
  }
48
+
49
+ return true;
48
50
  }
49
51
 
50
52
  /**
@@ -74,6 +76,9 @@ export async function enqueueActivity(collections, { activityType, actorUrl, obj
74
76
  });
75
77
  }
76
78
 
79
+ const BATCH_SIZE = 10;
80
+ const POLL_INTERVAL_MS = 1_000;
81
+
77
82
  /**
78
83
  * Start the background inbox processor.
79
84
  * @param {object} collections - MongoDB collections
@@ -85,14 +90,16 @@ export function startInboxProcessor(collections, getCtx, handle) {
85
90
  const intervalId = setInterval(async () => {
86
91
  try {
87
92
  const ctx = getCtx();
88
- if (ctx) {
89
- await processNextItem(collections, ctx, handle);
93
+ if (!ctx) return;
94
+ for (let i = 0; i < BATCH_SIZE; i++) {
95
+ const hadWork = await processNextItem(collections, ctx, handle);
96
+ if (!hadWork) break; // Queue empty, stop early
90
97
  }
91
98
  } catch (error) {
92
99
  console.error("[inbox-queue] Processor error:", error.message);
93
100
  }
94
- }, 3_000); // Every 3 seconds
101
+ }, POLL_INTERVAL_MS);
95
102
 
96
- console.info("[ActivityPub] Inbox queue processor started (3s interval)");
103
+ console.info(`[ActivityPub] Inbox queue processor started (${POLL_INTERVAL_MS}ms interval, batch size ${BATCH_SIZE})`);
97
104
  return intervalId;
98
105
  }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Create MongoDB indexes for all ActivityPub collections.
3
+ * Idempotent — safe to run on every startup.
4
+ * @module init-indexes
5
+ *
6
+ * @param {object} collections - MongoDB collections object
7
+ * @param {object} options
8
+ * @param {number} options.activityRetentionDays - TTL for ap_activities (0 = forever)
9
+ * @param {number} options.notificationRetentionDays - TTL for notifications (0 = forever)
10
+ */
11
+ export function createIndexes(collections, options) {
12
+ const { activityRetentionDays, notificationRetentionDays } = options;
13
+
14
+ // Create indexes — wrapped in try-catch because collection references
15
+ // may be undefined if MongoDB hasn't finished connecting yet.
16
+ // Indexes are idempotent; they'll be created on next successful startup.
17
+ try {
18
+ // TTL index for activity cleanup (MongoDB handles expiry automatically)
19
+ const retentionDays = activityRetentionDays;
20
+ if (retentionDays > 0) {
21
+ collections.ap_activities.createIndex(
22
+ { receivedAt: 1 },
23
+ { expireAfterSeconds: retentionDays * 86_400 },
24
+ );
25
+ }
26
+
27
+ // Performance indexes for inbox handlers and batch refollow
28
+ collections.ap_followers.createIndex(
29
+ { actorUrl: 1 },
30
+ { unique: true, background: true },
31
+ );
32
+ collections.ap_following.createIndex(
33
+ { actorUrl: 1 },
34
+ { unique: true, background: true },
35
+ );
36
+ collections.ap_following.createIndex(
37
+ { source: 1 },
38
+ { background: true },
39
+ );
40
+ collections.ap_activities.createIndex(
41
+ { objectUrl: 1 },
42
+ { background: true },
43
+ );
44
+ collections.ap_activities.createIndex(
45
+ { type: 1, actorUrl: 1, objectUrl: 1 },
46
+ { background: true },
47
+ );
48
+
49
+ // Reader indexes (timeline, notifications, moderation, interactions)
50
+ collections.ap_timeline.createIndex(
51
+ { uid: 1 },
52
+ { unique: true, background: true },
53
+ );
54
+ collections.ap_timeline.createIndex(
55
+ { published: -1 },
56
+ { background: true },
57
+ );
58
+ collections.ap_timeline.createIndex(
59
+ { "author.url": 1 },
60
+ { background: true },
61
+ );
62
+ collections.ap_timeline.createIndex(
63
+ { type: 1, published: -1 },
64
+ { background: true },
65
+ );
66
+
67
+ collections.ap_notifications.createIndex(
68
+ { uid: 1 },
69
+ { unique: true, background: true },
70
+ );
71
+ collections.ap_notifications.createIndex(
72
+ { published: -1 },
73
+ { background: true },
74
+ );
75
+ collections.ap_notifications.createIndex(
76
+ { read: 1 },
77
+ { background: true },
78
+ );
79
+ collections.ap_notifications.createIndex(
80
+ { type: 1, published: -1 },
81
+ { background: true },
82
+ );
83
+
84
+ // TTL index for notification cleanup
85
+ const notifRetention = notificationRetentionDays;
86
+ if (notifRetention > 0) {
87
+ collections.ap_notifications.createIndex(
88
+ { createdAt: 1 },
89
+ { expireAfterSeconds: notifRetention * 86_400 },
90
+ );
91
+ }
92
+
93
+ // Message indexes
94
+ collections.ap_messages.createIndex(
95
+ { uid: 1 },
96
+ { unique: true, background: true },
97
+ );
98
+ collections.ap_messages.createIndex(
99
+ { published: -1 },
100
+ { background: true },
101
+ );
102
+ collections.ap_messages.createIndex(
103
+ { read: 1 },
104
+ { background: true },
105
+ );
106
+ collections.ap_messages.createIndex(
107
+ { conversationId: 1, published: -1 },
108
+ { background: true },
109
+ );
110
+ collections.ap_messages.createIndex(
111
+ { direction: 1 },
112
+ { background: true },
113
+ );
114
+ // TTL index for message cleanup (reuse notification retention)
115
+ if (notifRetention > 0) {
116
+ collections.ap_messages.createIndex(
117
+ { createdAt: 1 },
118
+ { expireAfterSeconds: notifRetention * 86_400 },
119
+ );
120
+ }
121
+
122
+ // Muted collection — sparse unique indexes (allow multiple null values)
123
+ collections.ap_muted
124
+ .dropIndex("url_1")
125
+ .catch(() => {})
126
+ .then(() =>
127
+ collections.ap_muted.createIndex(
128
+ { url: 1 },
129
+ { unique: true, sparse: true, background: true },
130
+ ),
131
+ )
132
+ .catch(() => {});
133
+ collections.ap_muted
134
+ .dropIndex("keyword_1")
135
+ .catch(() => {})
136
+ .then(() =>
137
+ collections.ap_muted.createIndex(
138
+ { keyword: 1 },
139
+ { unique: true, sparse: true, background: true },
140
+ ),
141
+ )
142
+ .catch(() => {});
143
+
144
+ collections.ap_blocked.createIndex(
145
+ { url: 1 },
146
+ { unique: true, background: true },
147
+ );
148
+
149
+ collections.ap_interactions.createIndex(
150
+ { objectUrl: 1, type: 1 },
151
+ { unique: true, background: true },
152
+ );
153
+ collections.ap_interactions.createIndex(
154
+ { type: 1 },
155
+ { background: true },
156
+ );
157
+
158
+ // Followed hashtags — unique on tag (case-insensitive via normalization at write time)
159
+ collections.ap_followed_tags.createIndex(
160
+ { tag: 1 },
161
+ { unique: true, background: true },
162
+ );
163
+
164
+ // Tag filtering index on timeline
165
+ collections.ap_timeline.createIndex(
166
+ { category: 1, published: -1 },
167
+ { background: true },
168
+ );
169
+
170
+ // Explore tab indexes
171
+ // Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
172
+ // ALL insertions must explicitly set all four fields (unused fields = null)
173
+ // because MongoDB treats missing fields differently from null in unique indexes.
174
+ collections.ap_explore_tabs.createIndex(
175
+ { type: 1, domain: 1, scope: 1, hashtag: 1 },
176
+ { unique: true, background: true },
177
+ );
178
+ // Order index for efficient sorting of tab bar
179
+ collections.ap_explore_tabs.createIndex(
180
+ { order: 1 },
181
+ { background: true },
182
+ );
183
+
184
+ // ap_reports indexes
185
+ if (notifRetention > 0) {
186
+ collections.ap_reports.createIndex(
187
+ { createdAt: 1 },
188
+ { expireAfterSeconds: notifRetention * 86_400 },
189
+ );
190
+ }
191
+ collections.ap_reports.createIndex(
192
+ { reporterUrl: 1 },
193
+ { background: true },
194
+ );
195
+ collections.ap_reports.createIndex(
196
+ { reportedUrls: 1 },
197
+ { background: true },
198
+ );
199
+ // Pending follow requests — unique on actorUrl
200
+ collections.ap_pending_follows.createIndex(
201
+ { actorUrl: 1 },
202
+ { unique: true, background: true },
203
+ );
204
+ collections.ap_pending_follows.createIndex(
205
+ { requestedAt: -1 },
206
+ { background: true },
207
+ );
208
+ // Server-level blocks
209
+ collections.ap_blocked_servers.createIndex(
210
+ { hostname: 1 },
211
+ { unique: true, background: true },
212
+ );
213
+ // Key freshness tracking
214
+ collections.ap_key_freshness.createIndex(
215
+ { actorUrl: 1 },
216
+ { unique: true, background: true },
217
+ );
218
+
219
+ // Inbox queue indexes
220
+ collections.ap_inbox_queue.createIndex(
221
+ { status: 1, receivedAt: 1 },
222
+ { background: true },
223
+ );
224
+ // TTL: auto-prune completed items after 24h
225
+ collections.ap_inbox_queue.createIndex(
226
+ { processedAt: 1 },
227
+ { expireAfterSeconds: 86_400, background: true },
228
+ );
229
+
230
+ // Mastodon Client API indexes
231
+ collections.ap_oauth_apps.createIndex(
232
+ { clientId: 1 },
233
+ { unique: true, background: true },
234
+ );
235
+ collections.ap_oauth_tokens.createIndex(
236
+ { accessToken: 1 },
237
+ { unique: true, sparse: true, background: true },
238
+ );
239
+ collections.ap_oauth_tokens.createIndex(
240
+ { code: 1 },
241
+ { unique: true, sparse: true, background: true },
242
+ );
243
+ collections.ap_markers.createIndex(
244
+ { userId: 1, timeline: 1 },
245
+ { unique: true, background: true },
246
+ );
247
+ } catch {
248
+ // Index creation failed — collections not yet available.
249
+ // Indexes already exist from previous startups; non-fatal.
250
+ }
251
+ }
@@ -268,14 +268,32 @@ export async function renderItemCards(items, request, templateData) {
268
268
  return htmlParts.join("");
269
269
  }
270
270
 
271
+ // ─── Moderation data cache ──────────────────────────────────────────────────
272
+ let _moderationCache = null;
273
+ let _moderationCacheAt = 0;
274
+ const MODERATION_CACHE_TTL = 30_000; // 30 seconds
275
+
276
+ /**
277
+ * Invalidate the moderation data cache.
278
+ * Call this from any write operation that changes muted/blocked data.
279
+ */
280
+ export function invalidateModerationCache() {
281
+ _moderationCache = null;
282
+ _moderationCacheAt = 0;
283
+ }
284
+
271
285
  /**
272
286
  * Load moderation data from MongoDB collections.
273
- * Convenience wrapper to reduce boilerplate in controllers.
287
+ * Results are cached in memory for 30 seconds to avoid redundant queries.
274
288
  *
275
289
  * @param {object} modCollections - { ap_muted, ap_blocked, ap_profile }
276
290
  * @returns {Promise<object>} moderation data for postProcessItems()
277
291
  */
278
292
  export async function loadModerationData(modCollections) {
293
+ if (_moderationCache && Date.now() - _moderationCacheAt < MODERATION_CACHE_TTL) {
294
+ return _moderationCache;
295
+ }
296
+
279
297
  // Dynamic import to avoid circular dependency
280
298
  const { getMutedUrls, getMutedKeywords, getBlockedUrls, getFilterMode } =
281
299
  await import("./storage/moderation.js");
@@ -287,5 +305,7 @@ export async function loadModerationData(modCollections) {
287
305
  getFilterMode(modCollections),
288
306
  ]);
289
307
 
290
- return { mutedUrls, mutedKeywords, blockedUrls, filterMode };
308
+ _moderationCache = { mutedUrls, mutedKeywords, blockedUrls, filterMode };
309
+ _moderationCacheAt = Date.now();
310
+ return _moderationCache;
291
311
  }
@@ -20,6 +20,9 @@ export function getCached(url) {
20
20
  lookupCache.delete(url);
21
21
  return null;
22
22
  }
23
+ // Promote to end of Map (true LRU)
24
+ lookupCache.delete(url);
25
+ lookupCache.set(url, entry);
23
26
  return entry.data;
24
27
  }
25
28