@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.
- package/assets/css/base.css +144 -0
- package/assets/css/card.css +377 -0
- package/assets/css/compose.css +169 -0
- package/assets/css/dark-mode.css +94 -0
- package/assets/css/explore.css +530 -0
- package/assets/css/features.css +436 -0
- package/assets/css/federation.css +242 -0
- package/assets/css/interactions.css +236 -0
- package/assets/css/media.css +315 -0
- package/assets/css/messages.css +158 -0
- package/assets/css/moderation.css +119 -0
- package/assets/css/notifications.css +191 -0
- package/assets/css/profile.css +308 -0
- package/assets/css/responsive.css +33 -0
- package/assets/css/skeleton.css +74 -0
- package/assets/reader-interactions.js +115 -0
- package/assets/reader.css +20 -3439
- package/index.js +34 -694
- package/lib/batch-broadcast.js +98 -0
- package/lib/controllers/compose.js +5 -7
- package/lib/controllers/interactions-boost.js +8 -13
- package/lib/controllers/interactions-like.js +8 -13
- package/lib/federation-actions.js +70 -0
- package/lib/inbox-queue.js +13 -6
- package/lib/init-indexes.js +251 -0
- package/lib/item-processing.js +22 -2
- package/lib/lookup-cache.js +3 -0
- package/lib/mastodon/backfill-timeline.js +11 -2
- package/lib/mastodon/entities/sanitize.js +19 -88
- package/lib/mastodon/helpers/account-cache.js +3 -0
- package/lib/mastodon/helpers/enrich-accounts.js +42 -55
- package/lib/mastodon/router.js +31 -0
- package/lib/mastodon/routes/accounts.js +16 -49
- package/lib/mastodon/routes/media.js +6 -4
- package/lib/mastodon/routes/notifications.js +6 -24
- package/lib/mastodon/routes/oauth.js +91 -18
- package/lib/mastodon/routes/search.js +3 -1
- package/lib/mastodon/routes/statuses.js +14 -52
- package/lib/mastodon/routes/timelines.js +3 -6
- package/lib/og-unfurl.js +52 -33
- package/lib/storage/moderation.js +11 -2
- package/lib/syndicator.js +239 -0
- package/lib/timeline-store.js +11 -15
- package/package.json +2 -1
- package/views/activitypub-federation-mgmt.njk +2 -2
- package/views/activitypub-moderation.njk +1 -1
- package/views/activitypub-profile.njk +16 -76
- package/views/activitypub-reader.njk +2 -1
- package/views/layouts/ap-reader.njk +2 -0
- package/views/partials/ap-item-card.njk +14 -117
- package/views/partials/ap-item-content.njk +20 -0
- 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
|
|
73
|
+
if (!replyContext && isFederationReady(plugin)) {
|
|
73
74
|
try {
|
|
74
|
-
const handle = plugin
|
|
75
|
-
const ctx =
|
|
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
|
|
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
|
|
40
|
-
const ctx =
|
|
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.
|
|
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
|
|
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
|
|
186
|
-
const ctx =
|
|
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
|
|
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
|
|
42
|
-
const ctx =
|
|
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.
|
|
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
|
|
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
|
|
169
|
-
const ctx =
|
|
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
|
+
}
|
package/lib/inbox-queue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
},
|
|
101
|
+
}, POLL_INTERVAL_MS);
|
|
95
102
|
|
|
96
|
-
console.info(
|
|
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
|
+
}
|
package/lib/item-processing.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
308
|
+
_moderationCache = { mutedUrls, mutedKeywords, blockedUrls, filterMode };
|
|
309
|
+
_moderationCacheAt = Date.now();
|
|
310
|
+
return _moderationCache;
|
|
291
311
|
}
|