@rmdes/indiekit-endpoint-activitypub 1.0.29 → 1.1.2

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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Moderation storage operations (mute/block)
3
+ * @module storage/moderation
4
+ */
5
+
6
+ /**
7
+ * Add a muted URL or keyword
8
+ * @param {object} collections - MongoDB collections
9
+ * @param {object} data - Mute data
10
+ * @param {string} [data.url] - Actor URL to mute (mutually exclusive with keyword)
11
+ * @param {string} [data.keyword] - Keyword to mute (mutually exclusive with url)
12
+ * @returns {Promise<object>} Created mute entry
13
+ */
14
+ export async function addMuted(collections, { url, keyword }) {
15
+ const { ap_muted } = collections;
16
+
17
+ if (!url && !keyword) {
18
+ throw new Error("Either url or keyword must be provided");
19
+ }
20
+
21
+ if (url && keyword) {
22
+ throw new Error("Cannot mute both url and keyword in same entry");
23
+ }
24
+
25
+ const entry = {
26
+ url: url || null,
27
+ keyword: keyword || null,
28
+ mutedAt: new Date().toISOString(),
29
+ };
30
+
31
+ // Upsert to avoid duplicates
32
+ const filter = url ? { url } : { keyword };
33
+ await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
34
+
35
+ return await ap_muted.findOne(filter);
36
+ }
37
+
38
+ /**
39
+ * Remove a muted URL or keyword
40
+ * @param {object} collections - MongoDB collections
41
+ * @param {object} data - Mute identifier
42
+ * @param {string} [data.url] - Actor URL to unmute
43
+ * @param {string} [data.keyword] - Keyword to unmute
44
+ * @returns {Promise<object>} Delete result
45
+ */
46
+ export async function removeMuted(collections, { url, keyword }) {
47
+ const { ap_muted } = collections;
48
+
49
+ const filter = {};
50
+ if (url) {
51
+ filter.url = url;
52
+ } else if (keyword) {
53
+ filter.keyword = keyword;
54
+ } else {
55
+ throw new Error("Either url or keyword must be provided");
56
+ }
57
+
58
+ return await ap_muted.deleteOne(filter);
59
+ }
60
+
61
+ /**
62
+ * Get all muted URLs
63
+ * @param {object} collections - MongoDB collections
64
+ * @returns {Promise<string[]>} Array of muted URLs
65
+ */
66
+ export async function getMutedUrls(collections) {
67
+ const { ap_muted } = collections;
68
+ const entries = await ap_muted.find({ url: { $ne: null } }).toArray();
69
+ return entries.map((entry) => entry.url);
70
+ }
71
+
72
+ /**
73
+ * Get all muted keywords
74
+ * @param {object} collections - MongoDB collections
75
+ * @returns {Promise<string[]>} Array of muted keywords
76
+ */
77
+ export async function getMutedKeywords(collections) {
78
+ const { ap_muted } = collections;
79
+ const entries = await ap_muted.find({ keyword: { $ne: null } }).toArray();
80
+ return entries.map((entry) => entry.keyword);
81
+ }
82
+
83
+ /**
84
+ * Check if a URL is muted
85
+ * @param {object} collections - MongoDB collections
86
+ * @param {string} url - URL to check
87
+ * @returns {Promise<boolean>} True if muted
88
+ */
89
+ export async function isUrlMuted(collections, url) {
90
+ const { ap_muted } = collections;
91
+ const entry = await ap_muted.findOne({ url });
92
+ return !!entry;
93
+ }
94
+
95
+ /**
96
+ * Check if content contains muted keywords
97
+ * @param {object} collections - MongoDB collections
98
+ * @param {string} content - Content text to check
99
+ * @returns {Promise<boolean>} True if contains muted keyword
100
+ */
101
+ export async function containsMutedKeyword(collections, content) {
102
+ const keywords = await getMutedKeywords(collections);
103
+ const lowerContent = content.toLowerCase();
104
+
105
+ return keywords.some((keyword) => lowerContent.includes(keyword.toLowerCase()));
106
+ }
107
+
108
+ /**
109
+ * Add a blocked actor URL
110
+ * @param {object} collections - MongoDB collections
111
+ * @param {string} url - Actor URL to block
112
+ * @returns {Promise<object>} Created block entry
113
+ */
114
+ export async function addBlocked(collections, url) {
115
+ const { ap_blocked } = collections;
116
+
117
+ const entry = {
118
+ url,
119
+ blockedAt: new Date().toISOString(),
120
+ };
121
+
122
+ // Upsert to avoid duplicates
123
+ await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
124
+
125
+ return await ap_blocked.findOne({ url });
126
+ }
127
+
128
+ /**
129
+ * Remove a blocked actor URL
130
+ * @param {object} collections - MongoDB collections
131
+ * @param {string} url - Actor URL to unblock
132
+ * @returns {Promise<object>} Delete result
133
+ */
134
+ export async function removeBlocked(collections, url) {
135
+ const { ap_blocked } = collections;
136
+ return await ap_blocked.deleteOne({ url });
137
+ }
138
+
139
+ /**
140
+ * Get all blocked URLs
141
+ * @param {object} collections - MongoDB collections
142
+ * @returns {Promise<string[]>} Array of blocked URLs
143
+ */
144
+ export async function getBlockedUrls(collections) {
145
+ const { ap_blocked } = collections;
146
+ const entries = await ap_blocked.find({}).toArray();
147
+ return entries.map((entry) => entry.url);
148
+ }
149
+
150
+ /**
151
+ * Check if a URL is blocked
152
+ * @param {object} collections - MongoDB collections
153
+ * @param {string} url - URL to check
154
+ * @returns {Promise<boolean>} True if blocked
155
+ */
156
+ export async function isUrlBlocked(collections, url) {
157
+ const { ap_blocked } = collections;
158
+ const entry = await ap_blocked.findOne({ url });
159
+ return !!entry;
160
+ }
161
+
162
+ /**
163
+ * Get list of all muted entries (URLs and keywords)
164
+ * @param {object} collections - MongoDB collections
165
+ * @returns {Promise<object[]>} Array of mute entries
166
+ */
167
+ export async function getAllMuted(collections) {
168
+ const { ap_muted } = collections;
169
+ return await ap_muted.find({}).toArray();
170
+ }
171
+
172
+ /**
173
+ * Get list of all blocked entries
174
+ * @param {object} collections - MongoDB collections
175
+ * @returns {Promise<object[]>} Array of block entries
176
+ */
177
+ export async function getAllBlocked(collections) {
178
+ const { ap_blocked } = collections;
179
+ return await ap_blocked.find({}).toArray();
180
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Notification storage operations
3
+ * @module storage/notifications
4
+ */
5
+
6
+ /**
7
+ * Add a notification (uses atomic upsert for deduplication)
8
+ * @param {object} collections - MongoDB collections
9
+ * @param {object} notification - Notification data
10
+ * @param {string} notification.uid - Activity ID or constructed dedup key
11
+ * @param {string} notification.type - "like" | "boost" | "follow" | "mention" | "reply"
12
+ * @param {string} notification.actorUrl - Remote actor URL
13
+ * @param {string} notification.actorName - Display name
14
+ * @param {string} notification.actorPhoto - Avatar URL
15
+ * @param {string} notification.actorHandle - @user@instance
16
+ * @param {string} [notification.targetUrl] - The post they liked/boosted/replied to
17
+ * @param {string} [notification.targetName] - Post title
18
+ * @param {object} [notification.content] - { text, html } for mentions/replies
19
+ * @param {Date} notification.published - Activity timestamp (kept as Date for sort)
20
+ * @param {string} notification.createdAt - ISO string creation timestamp
21
+ * @returns {Promise<object>} Created or existing notification
22
+ */
23
+ export async function addNotification(collections, notification) {
24
+ const { ap_notifications } = collections;
25
+
26
+ const result = await ap_notifications.updateOne(
27
+ { uid: notification.uid },
28
+ {
29
+ $setOnInsert: {
30
+ ...notification,
31
+ read: false,
32
+ },
33
+ },
34
+ { upsert: true },
35
+ );
36
+
37
+ if (result.upsertedCount > 0) {
38
+ return await ap_notifications.findOne({ uid: notification.uid });
39
+ }
40
+
41
+ // Return existing document if it was a duplicate
42
+ return await ap_notifications.findOne({ uid: notification.uid });
43
+ }
44
+
45
+ /**
46
+ * Get notifications with cursor-based pagination
47
+ * @param {object} collections - MongoDB collections
48
+ * @param {object} options - Query options
49
+ * @param {string} [options.before] - Before cursor (published date)
50
+ * @param {number} [options.limit=20] - Items per page
51
+ * @param {boolean} [options.unreadOnly=false] - Show only unread notifications
52
+ * @returns {Promise<object>} { items, before }
53
+ */
54
+ export async function getNotifications(collections, options = {}) {
55
+ const { ap_notifications } = collections;
56
+ const parsedLimit = Number.parseInt(options.limit, 10);
57
+ const limit = Math.min(
58
+ Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
59
+ 100,
60
+ );
61
+
62
+ const query = {};
63
+
64
+ // Unread filter
65
+ if (options.unreadOnly) {
66
+ query.read = false;
67
+ }
68
+
69
+ // Cursor pagination
70
+ if (options.before) {
71
+ query.published = { $lt: new Date(options.before) };
72
+ }
73
+
74
+ const rawItems = await ap_notifications
75
+ .find(query)
76
+ .sort({ published: -1 })
77
+ .limit(limit)
78
+ .toArray();
79
+
80
+ // Normalize published dates to ISO strings for Nunjucks | date filter
81
+ const items = rawItems.map((item) => ({
82
+ ...item,
83
+ published: item.published instanceof Date
84
+ ? item.published.toISOString()
85
+ : item.published,
86
+ }));
87
+
88
+ // Generate cursor for next page
89
+ const before =
90
+ items.length > 0
91
+ ? items[items.length - 1].published
92
+ : null;
93
+
94
+ return {
95
+ items,
96
+ before,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Get count of unread notifications
102
+ * @param {object} collections - MongoDB collections
103
+ * @returns {Promise<number>} Unread notification count
104
+ */
105
+ export async function getUnreadNotificationCount(collections) {
106
+ const { ap_notifications } = collections;
107
+ return await ap_notifications.countDocuments({ read: false });
108
+ }
109
+
110
+ /**
111
+ * Mark notifications as read
112
+ * @param {object} collections - MongoDB collections
113
+ * @param {string[]} uids - Notification UIDs to mark read
114
+ * @returns {Promise<object>} Update result
115
+ */
116
+ export async function markNotificationsRead(collections, uids) {
117
+ const { ap_notifications } = collections;
118
+ return await ap_notifications.updateMany(
119
+ { uid: { $in: uids } },
120
+ { $set: { read: true } },
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Mark all notifications as read
126
+ * @param {object} collections - MongoDB collections
127
+ * @returns {Promise<object>} Update result
128
+ */
129
+ export async function markAllNotificationsRead(collections) {
130
+ const { ap_notifications } = collections;
131
+ return await ap_notifications.updateMany({}, { $set: { read: true } });
132
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Timeline item storage operations
3
+ * @module storage/timeline
4
+ */
5
+
6
+ /**
7
+ * Add a timeline item (uses atomic upsert for deduplication)
8
+ * @param {object} collections - MongoDB collections
9
+ * @param {object} item - Timeline item data
10
+ * @param {string} item.uid - Canonical AP object URL (dedup key)
11
+ * @param {string} item.type - "note" | "article" | "boost"
12
+ * @param {string} item.url - Post URL
13
+ * @param {string} [item.name] - Post title (articles only)
14
+ * @param {object} item.content - { text, html }
15
+ * @param {string} [item.summary] - Content warning text
16
+ * @param {boolean} item.sensitive - Sensitive content flag
17
+ * @param {Date} item.published - Published date (kept as Date for sort queries)
18
+ * @param {object} item.author - { name, url, photo, handle }
19
+ * @param {string[]} item.category - Tags/categories
20
+ * @param {string[]} item.photo - Photo URLs
21
+ * @param {string[]} item.video - Video URLs
22
+ * @param {string[]} item.audio - Audio URLs
23
+ * @param {string} [item.inReplyTo] - Parent post URL
24
+ * @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
25
+ * @param {Date} [item.boostedAt] - Boost timestamp
26
+ * @param {string} [item.originalUrl] - Original post URL for boosts
27
+ * @param {string} item.createdAt - ISO string creation timestamp
28
+ * @returns {Promise<object>} Created or existing item
29
+ */
30
+ export async function addTimelineItem(collections, item) {
31
+ const { ap_timeline } = collections;
32
+
33
+ const result = await ap_timeline.updateOne(
34
+ { uid: item.uid },
35
+ {
36
+ $setOnInsert: {
37
+ ...item,
38
+ readBy: [],
39
+ },
40
+ },
41
+ { upsert: true },
42
+ );
43
+
44
+ if (result.upsertedCount > 0) {
45
+ return await ap_timeline.findOne({ uid: item.uid });
46
+ }
47
+
48
+ // Return existing document if it was a duplicate
49
+ return await ap_timeline.findOne({ uid: item.uid });
50
+ }
51
+
52
+ /**
53
+ * Get timeline items with cursor-based pagination
54
+ * @param {object} collections - MongoDB collections
55
+ * @param {object} options - Query options
56
+ * @param {string} [options.before] - Before cursor (published date)
57
+ * @param {string} [options.after] - After cursor (published date)
58
+ * @param {number} [options.limit=20] - Items per page
59
+ * @param {string} [options.type] - Filter by type
60
+ * @param {string} [options.authorUrl] - Filter by author URL
61
+ * @returns {Promise<object>} { items, before, after }
62
+ */
63
+ export async function getTimelineItems(collections, options = {}) {
64
+ const { ap_timeline } = collections;
65
+ const parsedLimit = Number.parseInt(options.limit, 10);
66
+ const limit = Math.min(
67
+ Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
68
+ 100,
69
+ );
70
+
71
+ const query = {};
72
+
73
+ // Type filter
74
+ if (options.type) {
75
+ query.type = options.type;
76
+ }
77
+
78
+ // Author filter (for profile view) — validate string type to prevent operator injection
79
+ if (options.authorUrl) {
80
+ if (typeof options.authorUrl !== "string") {
81
+ throw new Error("Invalid authorUrl");
82
+ }
83
+
84
+ query["author.url"] = options.authorUrl;
85
+ }
86
+
87
+ // Cursor pagination — validate dates
88
+ if (options.before) {
89
+ const beforeDate = new Date(options.before);
90
+
91
+ if (Number.isNaN(beforeDate.getTime())) {
92
+ throw new Error("Invalid before cursor");
93
+ }
94
+
95
+ query.published = { $lt: beforeDate };
96
+ } else if (options.after) {
97
+ const afterDate = new Date(options.after);
98
+
99
+ if (Number.isNaN(afterDate.getTime())) {
100
+ throw new Error("Invalid after cursor");
101
+ }
102
+
103
+ query.published = { $gt: afterDate };
104
+ }
105
+
106
+ const rawItems = await ap_timeline
107
+ .find(query)
108
+ .sort({ published: -1 })
109
+ .limit(limit)
110
+ .toArray();
111
+
112
+ // Normalize published dates to ISO strings for Nunjucks | date filter
113
+ const items = rawItems.map((item) => ({
114
+ ...item,
115
+ published: item.published instanceof Date
116
+ ? item.published.toISOString()
117
+ : item.published,
118
+ }));
119
+
120
+ // Generate cursors for pagination
121
+ const before =
122
+ items.length > 0
123
+ ? items[0].published
124
+ : null;
125
+ const after =
126
+ items.length > 0
127
+ ? items[items.length - 1].published
128
+ : null;
129
+
130
+ return {
131
+ items,
132
+ before,
133
+ after,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Get a single timeline item by UID
139
+ * @param {object} collections - MongoDB collections
140
+ * @param {string} uid - Item UID (canonical URL)
141
+ * @returns {Promise<object|null>} Timeline item or null
142
+ */
143
+ export async function getTimelineItem(collections, uid) {
144
+ const { ap_timeline } = collections;
145
+ return await ap_timeline.findOne({ uid });
146
+ }
147
+
148
+ /**
149
+ * Delete a timeline item by UID
150
+ * @param {object} collections - MongoDB collections
151
+ * @param {string} uid - Item UID
152
+ * @returns {Promise<object>} Delete result
153
+ */
154
+ export async function deleteTimelineItem(collections, uid) {
155
+ const { ap_timeline } = collections;
156
+ return await ap_timeline.deleteOne({ uid });
157
+ }
158
+
159
+ /**
160
+ * Update a timeline item's content (for Update activities)
161
+ * @param {object} collections - MongoDB collections
162
+ * @param {string} uid - Item UID
163
+ * @param {object} updates - Fields to update
164
+ * @param {object} [updates.content] - New content
165
+ * @param {string} [updates.name] - New title
166
+ * @param {string} [updates.summary] - New content warning
167
+ * @param {boolean} [updates.sensitive] - New sensitive flag
168
+ * @returns {Promise<object>} Update result
169
+ */
170
+ export async function updateTimelineItem(collections, uid, updates) {
171
+ const { ap_timeline } = collections;
172
+ return await ap_timeline.updateOne({ uid }, { $set: updates });
173
+ }
174
+
175
+ /**
176
+ * Delete timeline items older than a cutoff date (retention cleanup)
177
+ * @param {object} collections - MongoDB collections
178
+ * @param {Date} cutoffDate - Delete items published before this date
179
+ * @returns {Promise<number>} Number of items deleted
180
+ */
181
+ export async function deleteOldTimelineItems(collections, cutoffDate) {
182
+ const { ap_timeline } = collections;
183
+ const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } });
184
+ return result.deletedCount;
185
+ }
186
+
187
+ /**
188
+ * Delete timeline items by count-based retention (keep N newest)
189
+ * @param {object} collections - MongoDB collections
190
+ * @param {number} keepCount - Number of items to keep
191
+ * @returns {Promise<number>} Number of items deleted
192
+ */
193
+ export async function cleanupTimelineByCount(collections, keepCount) {
194
+ const { ap_timeline } = collections;
195
+
196
+ // Find the Nth newest item's published date
197
+ const items = await ap_timeline
198
+ .find({})
199
+ .sort({ published: -1 })
200
+ .skip(keepCount)
201
+ .limit(1)
202
+ .toArray();
203
+
204
+ if (items.length === 0) {
205
+ return 0; // Fewer than keepCount items exist
206
+ }
207
+
208
+ const cutoffDate = items[0].published;
209
+ return await deleteOldTimelineItems(collections, cutoffDate);
210
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Timeline retention cleanup — removes old timeline items to prevent
3
+ * unbounded collection growth and cleans up stale interaction tracking.
4
+ */
5
+
6
+ /**
7
+ * Remove timeline items beyond the retention limit and clean up
8
+ * corresponding ap_interactions entries.
9
+ *
10
+ * Uses aggregation to identify exact items to delete by UID,
11
+ * avoiding race conditions between finding and deleting.
12
+ *
13
+ * @param {object} collections - MongoDB collections
14
+ * @param {number} retentionLimit - Max number of timeline items to keep
15
+ * @returns {Promise<{removed: number, interactionsRemoved: number}>}
16
+ */
17
+ export async function cleanupTimeline(collections, retentionLimit) {
18
+ if (!collections.ap_timeline || retentionLimit <= 0) {
19
+ return { removed: 0, interactionsRemoved: 0 };
20
+ }
21
+
22
+ const totalCount = await collections.ap_timeline.countDocuments();
23
+ if (totalCount <= retentionLimit) {
24
+ return { removed: 0, interactionsRemoved: 0 };
25
+ }
26
+
27
+ // Use aggregation to get exact UIDs beyond the retention limit.
28
+ // This avoids race conditions: we delete by UID, not by date.
29
+ const toDelete = await collections.ap_timeline
30
+ .aggregate([
31
+ { $sort: { published: -1 } },
32
+ { $skip: retentionLimit },
33
+ { $project: { uid: 1 } },
34
+ ])
35
+ .toArray();
36
+
37
+ if (!toDelete.length) {
38
+ return { removed: 0, interactionsRemoved: 0 };
39
+ }
40
+
41
+ const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
42
+
43
+ // Delete old timeline items by UID
44
+ const deleteResult = await collections.ap_timeline.deleteMany({
45
+ _id: { $in: toDelete.map((item) => item._id) },
46
+ });
47
+
48
+ // Clean up stale interactions for removed items
49
+ let interactionsRemoved = 0;
50
+ if (removedUids.length > 0 && collections.ap_interactions) {
51
+ const interactionResult = await collections.ap_interactions.deleteMany({
52
+ objectUrl: { $in: removedUids },
53
+ });
54
+ interactionsRemoved = interactionResult.deletedCount || 0;
55
+ }
56
+
57
+ const removed = deleteResult.deletedCount || 0;
58
+
59
+ if (removed > 0) {
60
+ console.info(
61
+ `[ActivityPub] Timeline cleanup: removed ${removed} items, ${interactionsRemoved} stale interactions`,
62
+ );
63
+ }
64
+
65
+ return { removed, interactionsRemoved };
66
+ }
67
+
68
+ /**
69
+ * Schedule periodic timeline cleanup.
70
+ *
71
+ * @param {object} collections - MongoDB collections
72
+ * @param {number} retentionLimit - Max number of timeline items to keep
73
+ * @param {number} intervalMs - Cleanup interval in milliseconds (default: 24 hours)
74
+ * @returns {NodeJS.Timeout} The interval timer (for cleanup if needed)
75
+ */
76
+ export function scheduleCleanup(collections, retentionLimit, intervalMs = 86_400_000) {
77
+ // Run immediately on startup
78
+ cleanupTimeline(collections, retentionLimit).catch((error) => {
79
+ console.error("[ActivityPub] Timeline cleanup failed:", error.message);
80
+ });
81
+
82
+ // Then run periodically
83
+ return setInterval(() => {
84
+ cleanupTimeline(collections, retentionLimit).catch((error) => {
85
+ console.error("[ActivityPub] Timeline cleanup failed:", error.message);
86
+ });
87
+ }, intervalMs);
88
+ }