@rmdes/indiekit-endpoint-microsub 1.0.0-beta.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.
Files changed (52) hide show
  1. package/README.md +111 -0
  2. package/index.js +140 -0
  3. package/lib/cache/redis.js +133 -0
  4. package/lib/controllers/block.js +85 -0
  5. package/lib/controllers/channels.js +135 -0
  6. package/lib/controllers/events.js +56 -0
  7. package/lib/controllers/follow.js +108 -0
  8. package/lib/controllers/microsub.js +138 -0
  9. package/lib/controllers/mute.js +124 -0
  10. package/lib/controllers/preview.js +67 -0
  11. package/lib/controllers/reader.js +218 -0
  12. package/lib/controllers/search.js +142 -0
  13. package/lib/controllers/timeline.js +117 -0
  14. package/lib/feeds/atom.js +61 -0
  15. package/lib/feeds/fetcher.js +205 -0
  16. package/lib/feeds/hfeed.js +177 -0
  17. package/lib/feeds/jsonfeed.js +43 -0
  18. package/lib/feeds/normalizer.js +586 -0
  19. package/lib/feeds/parser.js +124 -0
  20. package/lib/feeds/rss.js +61 -0
  21. package/lib/polling/processor.js +201 -0
  22. package/lib/polling/scheduler.js +128 -0
  23. package/lib/polling/tier.js +139 -0
  24. package/lib/realtime/broker.js +241 -0
  25. package/lib/search/indexer.js +90 -0
  26. package/lib/search/query.js +197 -0
  27. package/lib/storage/channels.js +281 -0
  28. package/lib/storage/feeds.js +286 -0
  29. package/lib/storage/filters.js +265 -0
  30. package/lib/storage/items.js +419 -0
  31. package/lib/storage/read-state.js +109 -0
  32. package/lib/utils/jf2.js +170 -0
  33. package/lib/utils/pagination.js +157 -0
  34. package/lib/utils/validation.js +217 -0
  35. package/lib/webmention/processor.js +214 -0
  36. package/lib/webmention/receiver.js +54 -0
  37. package/lib/webmention/verifier.js +308 -0
  38. package/lib/websub/discovery.js +129 -0
  39. package/lib/websub/handler.js +163 -0
  40. package/lib/websub/subscriber.js +181 -0
  41. package/locales/en.json +80 -0
  42. package/package.json +54 -0
  43. package/views/channel-new.njk +33 -0
  44. package/views/channel.njk +41 -0
  45. package/views/compose.njk +61 -0
  46. package/views/item.njk +85 -0
  47. package/views/partials/actions.njk +15 -0
  48. package/views/partials/author.njk +17 -0
  49. package/views/partials/item-card.njk +65 -0
  50. package/views/partials/timeline.njk +10 -0
  51. package/views/reader.njk +37 -0
  52. package/views/settings.njk +81 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Filter storage operations (mute, block, channel filters)
3
+ * @module storage/filters
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ /**
9
+ * Get muted collection
10
+ * @param {object} application - Indiekit application
11
+ * @returns {object} MongoDB collection
12
+ */
13
+ function getMutedCollection(application) {
14
+ return application.collections.get("microsub_muted");
15
+ }
16
+
17
+ /**
18
+ * Get blocked collection
19
+ * @param {object} application - Indiekit application
20
+ * @returns {object} MongoDB collection
21
+ */
22
+ function getBlockedCollection(application) {
23
+ return application.collections.get("microsub_blocked");
24
+ }
25
+
26
+ /**
27
+ * Check if a URL is muted for a user/channel
28
+ * @param {object} application - Indiekit application
29
+ * @param {string} userId - User ID
30
+ * @param {ObjectId|string} channelId - Channel ObjectId
31
+ * @param {string} url - URL to check
32
+ * @returns {Promise<boolean>} Whether the URL is muted
33
+ */
34
+ export async function isMuted(application, userId, channelId, url) {
35
+ const collection = getMutedCollection(application);
36
+ const channelObjectId =
37
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
38
+
39
+ // Check for channel-specific mute
40
+ const channelMute = await collection.findOne({
41
+ userId,
42
+ channelId: channelObjectId,
43
+ url,
44
+ });
45
+ if (channelMute) return true;
46
+
47
+ // Check for global mute (no channelId)
48
+ const globalMute = await collection.findOne({
49
+ userId,
50
+ channelId: { $exists: false },
51
+ url,
52
+ });
53
+ return !!globalMute;
54
+ }
55
+
56
+ /**
57
+ * Check if a URL is blocked for a user
58
+ * @param {object} application - Indiekit application
59
+ * @param {string} userId - User ID
60
+ * @param {string} url - URL to check
61
+ * @returns {Promise<boolean>} Whether the URL is blocked
62
+ */
63
+ export async function isBlocked(application, userId, url) {
64
+ const collection = getBlockedCollection(application);
65
+ const blocked = await collection.findOne({ userId, url });
66
+ return !!blocked;
67
+ }
68
+
69
+ /**
70
+ * Check if an item passes all filters
71
+ * @param {object} application - Indiekit application
72
+ * @param {string} userId - User ID
73
+ * @param {object} channel - Channel document with settings
74
+ * @param {object} item - Feed item to check
75
+ * @returns {Promise<boolean>} Whether the item passes all filters
76
+ */
77
+ export async function passesAllFilters(application, userId, channel, item) {
78
+ // Check if author URL is blocked
79
+ if (
80
+ item.author?.url &&
81
+ (await isBlocked(application, userId, item.author.url))
82
+ ) {
83
+ return false;
84
+ }
85
+
86
+ // Check if source URL is muted
87
+ if (
88
+ item._source?.url &&
89
+ (await isMuted(application, userId, channel._id, item._source.url))
90
+ ) {
91
+ return false;
92
+ }
93
+
94
+ // Check channel settings filters
95
+ if (channel?.settings) {
96
+ // Check excludeTypes
97
+ if (!passesTypeFilter(item, channel.settings)) {
98
+ return false;
99
+ }
100
+
101
+ // Check excludeRegex
102
+ if (!passesRegexFilter(item, channel.settings)) {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * Check if an item passes the excludeTypes filter
112
+ * @param {object} item - Feed item
113
+ * @param {object} settings - Channel settings
114
+ * @returns {boolean} Whether the item passes
115
+ */
116
+ export function passesTypeFilter(item, settings) {
117
+ if (!settings.excludeTypes || settings.excludeTypes.length === 0) {
118
+ return true;
119
+ }
120
+
121
+ const itemType = detectInteractionType(item);
122
+ return !settings.excludeTypes.includes(itemType);
123
+ }
124
+
125
+ /**
126
+ * Check if an item passes the excludeRegex filter
127
+ * @param {object} item - Feed item
128
+ * @param {object} settings - Channel settings
129
+ * @returns {boolean} Whether the item passes
130
+ */
131
+ export function passesRegexFilter(item, settings) {
132
+ if (!settings.excludeRegex) {
133
+ return true;
134
+ }
135
+
136
+ try {
137
+ const regex = new RegExp(settings.excludeRegex, "i");
138
+ const searchText = [
139
+ item.name,
140
+ item.summary,
141
+ item.content?.text,
142
+ item.content?.html,
143
+ ]
144
+ .filter(Boolean)
145
+ .join(" ");
146
+
147
+ return !regex.test(searchText);
148
+ } catch {
149
+ // Invalid regex, skip filter
150
+ return true;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Detect the interaction type of an item
156
+ * @param {object} item - Feed item
157
+ * @returns {string} Interaction type
158
+ */
159
+ export function detectInteractionType(item) {
160
+ if (item["like-of"] && item["like-of"].length > 0) {
161
+ return "like";
162
+ }
163
+ if (item["repost-of"] && item["repost-of"].length > 0) {
164
+ return "repost";
165
+ }
166
+ if (item["bookmark-of"] && item["bookmark-of"].length > 0) {
167
+ return "bookmark";
168
+ }
169
+ if (item["in-reply-to"] && item["in-reply-to"].length > 0) {
170
+ return "reply";
171
+ }
172
+ if (item.rsvp) {
173
+ return "rsvp";
174
+ }
175
+ if (item.checkin) {
176
+ return "checkin";
177
+ }
178
+
179
+ return "post";
180
+ }
181
+
182
+ /**
183
+ * Get all muted URLs for a user/channel
184
+ * @param {object} application - Indiekit application
185
+ * @param {string} userId - User ID
186
+ * @param {ObjectId|string} [channelId] - Channel ObjectId (optional, for channel-specific)
187
+ * @returns {Promise<Array>} Array of muted URLs
188
+ */
189
+ export async function getMutedUrls(application, userId, channelId) {
190
+ const collection = getMutedCollection(application);
191
+ const filter = { userId };
192
+
193
+ if (channelId) {
194
+ const channelObjectId =
195
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
196
+ filter.channelId = channelObjectId;
197
+ }
198
+
199
+ // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
200
+ const muted = await collection.find(filter).toArray();
201
+ return muted.map((m) => m.url);
202
+ }
203
+
204
+ /**
205
+ * Get all blocked URLs for a user
206
+ * @param {object} application - Indiekit application
207
+ * @param {string} userId - User ID
208
+ * @returns {Promise<Array>} Array of blocked URLs
209
+ */
210
+ export async function getBlockedUrls(application, userId) {
211
+ const collection = getBlockedCollection(application);
212
+ const blocked = await collection.find({ userId }).toArray();
213
+ return blocked.map((b) => b.url);
214
+ }
215
+
216
+ /**
217
+ * Update channel filter settings
218
+ * @param {object} application - Indiekit application
219
+ * @param {ObjectId|string} channelId - Channel ObjectId
220
+ * @param {object} filters - Filter settings to update
221
+ * @param {Array} [filters.excludeTypes] - Post types to exclude
222
+ * @param {string} [filters.excludeRegex] - Regex pattern to exclude
223
+ * @returns {Promise<object>} Updated channel
224
+ */
225
+ export async function updateChannelFilters(application, channelId, filters) {
226
+ const collection = application.collections.get("microsub_channels");
227
+ const channelObjectId =
228
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
229
+
230
+ const updateFields = {};
231
+
232
+ if (filters.excludeTypes !== undefined) {
233
+ updateFields["settings.excludeTypes"] = filters.excludeTypes;
234
+ }
235
+
236
+ if (filters.excludeRegex !== undefined) {
237
+ updateFields["settings.excludeRegex"] = filters.excludeRegex;
238
+ }
239
+
240
+ const result = await collection.findOneAndUpdate(
241
+ { _id: channelObjectId },
242
+ { $set: updateFields },
243
+ { returnDocument: "after" },
244
+ );
245
+
246
+ return result;
247
+ }
248
+
249
+ /**
250
+ * Create indexes for filter collections
251
+ * @param {object} application - Indiekit application
252
+ * @returns {Promise<void>}
253
+ */
254
+ export async function createFilterIndexes(application) {
255
+ const mutedCollection = getMutedCollection(application);
256
+ const blockedCollection = getBlockedCollection(application);
257
+
258
+ // Muted collection indexes
259
+ await mutedCollection.createIndex({ userId: 1, channelId: 1, url: 1 });
260
+ await mutedCollection.createIndex({ userId: 1 });
261
+
262
+ // Blocked collection indexes
263
+ await blockedCollection.createIndex({ userId: 1, url: 1 }, { unique: true });
264
+ await blockedCollection.createIndex({ userId: 1 });
265
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Timeline item storage operations
3
+ * @module storage/items
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ import {
9
+ buildPaginationQuery,
10
+ buildPaginationSort,
11
+ generatePagingCursors,
12
+ parseLimit,
13
+ } from "../utils/pagination.js";
14
+
15
+ /**
16
+ * Get items collection from application
17
+ * @param {object} application - Indiekit application
18
+ * @returns {object} MongoDB collection
19
+ */
20
+ function getCollection(application) {
21
+ return application.collections.get("microsub_items");
22
+ }
23
+
24
+ /**
25
+ * Add an item to a channel
26
+ * @param {object} application - Indiekit application
27
+ * @param {object} data - Item data
28
+ * @param {ObjectId} data.channelId - Channel ObjectId
29
+ * @param {ObjectId} data.feedId - Feed ObjectId
30
+ * @param {string} data.uid - Unique item identifier
31
+ * @param {object} data.item - jf2 item data
32
+ * @returns {Promise<object|null>} Created item or null if duplicate
33
+ */
34
+ export async function addItem(application, { channelId, feedId, uid, item }) {
35
+ const collection = getCollection(application);
36
+
37
+ // Check for duplicate
38
+ const existing = await collection.findOne({ channelId, uid });
39
+ if (existing) {
40
+ return; // Duplicate, don't add
41
+ }
42
+
43
+ const document = {
44
+ channelId,
45
+ feedId,
46
+ uid,
47
+ type: item.type || "entry",
48
+ url: item.url,
49
+ name: item.name || undefined,
50
+ content: item.content || undefined,
51
+ summary: item.summary || undefined,
52
+ published: item.published ? new Date(item.published) : new Date(),
53
+ updated: item.updated ? new Date(item.updated) : undefined,
54
+ author: item.author || undefined,
55
+ category: item.category || [],
56
+ photo: item.photo || [],
57
+ video: item.video || [],
58
+ audio: item.audio || [],
59
+ likeOf: item["like-of"] || item.likeOf || [],
60
+ repostOf: item["repost-of"] || item.repostOf || [],
61
+ bookmarkOf: item["bookmark-of"] || item.bookmarkOf || [],
62
+ inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
63
+ source: item._source || undefined,
64
+ readBy: [], // Array of user IDs who have read this item
65
+ createdAt: new Date(),
66
+ };
67
+
68
+ await collection.insertOne(document);
69
+ return document;
70
+ }
71
+
72
+ /**
73
+ * Get timeline items for a channel
74
+ * @param {object} application - Indiekit application
75
+ * @param {ObjectId|string} channelId - Channel ObjectId
76
+ * @param {object} options - Query options
77
+ * @param {string} [options.before] - Before cursor
78
+ * @param {string} [options.after] - After cursor
79
+ * @param {number} [options.limit] - Items per page
80
+ * @param {string} [options.userId] - User ID for read state
81
+ * @returns {Promise<object>} Timeline with items and paging
82
+ */
83
+ export async function getTimelineItems(application, channelId, options = {}) {
84
+ const collection = getCollection(application);
85
+ const objectId =
86
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
87
+ const limit = parseLimit(options.limit);
88
+
89
+ const baseQuery = { channelId: objectId };
90
+ const query = buildPaginationQuery({
91
+ before: options.before,
92
+ after: options.after,
93
+ baseQuery,
94
+ });
95
+
96
+ const sort = buildPaginationSort(options.before);
97
+
98
+ // Fetch one extra to check if there are more
99
+ const items = await collection
100
+ // eslint-disable-next-line unicorn/no-array-callback-reference -- query is MongoDB query object
101
+ .find(query)
102
+ .toSorted(sort)
103
+ .limit(limit + 1)
104
+ .toArray();
105
+
106
+ const hasMore = items.length > limit;
107
+ if (hasMore) {
108
+ items.pop();
109
+ }
110
+
111
+ // Transform to jf2 format
112
+ const jf2Items = items.map((item) => transformToJf2(item, options.userId));
113
+
114
+ // Generate paging cursors
115
+ const paging = generatePagingCursors(items, limit, hasMore, options.before);
116
+
117
+ return {
118
+ items: jf2Items,
119
+ paging,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Transform database item to jf2 format
125
+ * @param {object} item - Database item
126
+ * @param {string} [userId] - User ID for read state
127
+ * @returns {object} jf2 item
128
+ */
129
+ function transformToJf2(item, userId) {
130
+ const jf2 = {
131
+ type: item.type,
132
+ uid: item.uid,
133
+ url: item.url,
134
+ published: item.published?.toISOString(),
135
+ _id: item._id.toString(),
136
+ _is_read: userId ? item.readBy?.includes(userId) : false,
137
+ };
138
+
139
+ // Optional fields
140
+ if (item.name) jf2.name = item.name;
141
+ if (item.content) jf2.content = item.content;
142
+ if (item.summary) jf2.summary = item.summary;
143
+ if (item.updated) jf2.updated = item.updated.toISOString();
144
+ if (item.author) jf2.author = item.author;
145
+ if (item.category?.length > 0) jf2.category = item.category;
146
+ if (item.photo?.length > 0) jf2.photo = item.photo;
147
+ if (item.video?.length > 0) jf2.video = item.video;
148
+ if (item.audio?.length > 0) jf2.audio = item.audio;
149
+
150
+ // Interaction types
151
+ if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
152
+ if (item.repostOf?.length > 0) jf2["repost-of"] = item.repostOf;
153
+ if (item.bookmarkOf?.length > 0) jf2["bookmark-of"] = item.bookmarkOf;
154
+ if (item.inReplyTo?.length > 0) jf2["in-reply-to"] = item.inReplyTo;
155
+
156
+ // Source
157
+ if (item.source) jf2._source = item.source;
158
+
159
+ return jf2;
160
+ }
161
+
162
+ /**
163
+ * Get an item by ID
164
+ * @param {object} application - Indiekit application
165
+ * @param {ObjectId|string} id - Item ObjectId
166
+ * @param {string} [userId] - User ID for read state
167
+ * @returns {Promise<object|null>} jf2 item or null
168
+ */
169
+ export async function getItemById(application, id, userId) {
170
+ const collection = getCollection(application);
171
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
172
+
173
+ const item = await collection.findOne({ _id: objectId });
174
+ if (!item) return;
175
+
176
+ return transformToJf2(item, userId);
177
+ }
178
+
179
+ /**
180
+ * Get items by UIDs
181
+ * @param {object} application - Indiekit application
182
+ * @param {Array} uids - Array of item UIDs
183
+ * @param {string} [userId] - User ID for read state
184
+ * @returns {Promise<Array>} Array of jf2 items
185
+ */
186
+ export async function getItemsByUids(application, uids, userId) {
187
+ const collection = getCollection(application);
188
+
189
+ const items = await collection.find({ uid: { $in: uids } }).toArray();
190
+ return items.map((item) => transformToJf2(item, userId));
191
+ }
192
+
193
+ /**
194
+ * Mark items as read
195
+ * @param {object} application - Indiekit application
196
+ * @param {ObjectId|string} channelId - Channel ObjectId
197
+ * @param {Array} entryIds - Array of entry IDs to mark as read
198
+ * @param {string} userId - User ID
199
+ * @returns {Promise<number>} Number of items updated
200
+ */
201
+ export async function markItemsRead(application, channelId, entryIds, userId) {
202
+ const collection = getCollection(application);
203
+ const channelObjectId =
204
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
205
+
206
+ // Handle "last-read-entry" special value
207
+ if (entryIds.includes("last-read-entry")) {
208
+ // Mark all items in channel as read
209
+ const result = await collection.updateMany(
210
+ { channelId: channelObjectId },
211
+ { $addToSet: { readBy: userId } },
212
+ );
213
+ return result.modifiedCount;
214
+ }
215
+
216
+ // Convert string IDs to ObjectIds where possible
217
+ const objectIds = entryIds.map((id) => {
218
+ try {
219
+ return new ObjectId(id);
220
+ } catch {
221
+ return id;
222
+ }
223
+ });
224
+
225
+ const result = await collection.updateMany(
226
+ {
227
+ channelId: channelObjectId,
228
+ $or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
229
+ },
230
+ { $addToSet: { readBy: userId } },
231
+ );
232
+
233
+ return result.modifiedCount;
234
+ }
235
+
236
+ /**
237
+ * Mark items as unread
238
+ * @param {object} application - Indiekit application
239
+ * @param {ObjectId|string} channelId - Channel ObjectId
240
+ * @param {Array} entryIds - Array of entry IDs to mark as unread
241
+ * @param {string} userId - User ID
242
+ * @returns {Promise<number>} Number of items updated
243
+ */
244
+ export async function markItemsUnread(
245
+ application,
246
+ channelId,
247
+ entryIds,
248
+ userId,
249
+ ) {
250
+ const collection = getCollection(application);
251
+ const channelObjectId =
252
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
253
+
254
+ const objectIds = entryIds.map((id) => {
255
+ try {
256
+ return new ObjectId(id);
257
+ } catch {
258
+ return id;
259
+ }
260
+ });
261
+
262
+ const result = await collection.updateMany(
263
+ {
264
+ channelId: channelObjectId,
265
+ $or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
266
+ },
267
+ { $pull: { readBy: userId } },
268
+ );
269
+
270
+ return result.modifiedCount;
271
+ }
272
+
273
+ /**
274
+ * Remove items from channel
275
+ * @param {object} application - Indiekit application
276
+ * @param {ObjectId|string} channelId - Channel ObjectId
277
+ * @param {Array} entryIds - Array of entry IDs to remove
278
+ * @returns {Promise<number>} Number of items removed
279
+ */
280
+ export async function removeItems(application, channelId, entryIds) {
281
+ const collection = getCollection(application);
282
+ const channelObjectId =
283
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
284
+
285
+ const objectIds = entryIds.map((id) => {
286
+ try {
287
+ return new ObjectId(id);
288
+ } catch {
289
+ return id;
290
+ }
291
+ });
292
+
293
+ const result = await collection.deleteMany({
294
+ channelId: channelObjectId,
295
+ $or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
296
+ });
297
+
298
+ return result.deletedCount;
299
+ }
300
+
301
+ /**
302
+ * Delete all items for a channel
303
+ * @param {object} application - Indiekit application
304
+ * @param {ObjectId|string} channelId - Channel ObjectId
305
+ * @returns {Promise<number>} Number of deleted items
306
+ */
307
+ export async function deleteItemsForChannel(application, channelId) {
308
+ const collection = getCollection(application);
309
+ const objectId =
310
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
311
+
312
+ const result = await collection.deleteMany({ channelId: objectId });
313
+ return result.deletedCount;
314
+ }
315
+
316
+ /**
317
+ * Delete items for a specific feed
318
+ * @param {object} application - Indiekit application
319
+ * @param {ObjectId|string} feedId - Feed ObjectId
320
+ * @returns {Promise<number>} Number of deleted items
321
+ */
322
+ export async function deleteItemsForFeed(application, feedId) {
323
+ const collection = getCollection(application);
324
+ const objectId = typeof feedId === "string" ? new ObjectId(feedId) : feedId;
325
+
326
+ const result = await collection.deleteMany({ feedId: objectId });
327
+ return result.deletedCount;
328
+ }
329
+
330
+ /**
331
+ * Get unread count for a channel
332
+ * @param {object} application - Indiekit application
333
+ * @param {ObjectId|string} channelId - Channel ObjectId
334
+ * @param {string} userId - User ID
335
+ * @returns {Promise<number>} Unread count
336
+ */
337
+ export async function getUnreadCount(application, channelId, userId) {
338
+ const collection = getCollection(application);
339
+ const objectId =
340
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
341
+
342
+ return collection.countDocuments({
343
+ channelId: objectId,
344
+ readBy: { $ne: userId },
345
+ });
346
+ }
347
+
348
+ /**
349
+ * Search items by text
350
+ * @param {object} application - Indiekit application
351
+ * @param {ObjectId|string} channelId - Channel ObjectId
352
+ * @param {string} query - Search query
353
+ * @param {number} [limit] - Max results
354
+ * @returns {Promise<Array>} Array of matching items
355
+ */
356
+ export async function searchItems(application, channelId, query, limit = 20) {
357
+ const collection = getCollection(application);
358
+ const objectId =
359
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
360
+
361
+ // Use regex search (consider adding text index for better performance)
362
+ const regex = new RegExp(query, "i");
363
+ const items = await collection
364
+ .find({
365
+ channelId: objectId,
366
+ $or: [
367
+ { name: regex },
368
+ { "content.text": regex },
369
+ { "content.html": regex },
370
+ { summary: regex },
371
+ ],
372
+ })
373
+ .toSorted({ published: -1 })
374
+ .limit(limit)
375
+ .toArray();
376
+
377
+ return items.map((item) => transformToJf2(item));
378
+ }
379
+
380
+ /**
381
+ * Delete items by author URL (for blocking)
382
+ * @param {object} application - Indiekit application
383
+ * @param {string} userId - User ID (for filtering user's channels)
384
+ * @param {string} authorUrl - Author URL to delete items from
385
+ * @returns {Promise<number>} Number of deleted items
386
+ */
387
+ export async function deleteItemsByAuthorUrl(application, userId, authorUrl) {
388
+ const collection = getCollection(application);
389
+ const channelsCollection = application.collections.get("microsub_channels");
390
+
391
+ // Get all channel IDs for this user
392
+ const userChannels = await channelsCollection.find({ userId }).toArray();
393
+ const channelIds = userChannels.map((c) => c._id);
394
+
395
+ // Delete all items from blocked author in user's channels
396
+ const result = await collection.deleteMany({
397
+ channelId: { $in: channelIds },
398
+ "author.url": authorUrl,
399
+ });
400
+
401
+ return result.deletedCount;
402
+ }
403
+
404
+ /**
405
+ * Create indexes for efficient queries
406
+ * @param {object} application - Indiekit application
407
+ * @returns {Promise<void>}
408
+ */
409
+ export async function createIndexes(application) {
410
+ const collection = getCollection(application);
411
+
412
+ await collection.createIndex({ channelId: 1, published: -1 });
413
+ await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true });
414
+ await collection.createIndex({ feedId: 1 });
415
+ await collection.createIndex(
416
+ { name: "text", "content.text": "text", summary: "text" },
417
+ { name: "text_search" },
418
+ );
419
+ }