@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.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.
@@ -23,6 +23,9 @@ import {
23
23
  } from "@fedify/fedify";
24
24
 
25
25
  import { logActivity as logActivityShared } from "./activity-log.js";
26
+ import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
27
+ import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
28
+ import { addNotification } from "./storage/notifications.js";
26
29
 
27
30
  /**
28
31
  * Register all inbox listeners on a federation's inbox chain.
@@ -83,6 +86,19 @@ export function registerInboxListeners(inboxChain, options) {
83
86
  actorName: followerName,
84
87
  summary: `${followerName} followed you`,
85
88
  });
89
+
90
+ // Store notification
91
+ const followerInfo = await extractActorInfo(followerActor);
92
+ await addNotification(collections, {
93
+ uid: follow.id?.href || `follow:${followerUrl}`,
94
+ type: "follow",
95
+ actorUrl: followerInfo.url,
96
+ actorName: followerInfo.name,
97
+ actorPhoto: followerInfo.photo,
98
+ actorHandle: followerInfo.handle,
99
+ published: follow.published ? new Date(follow.published) : new Date(),
100
+ createdAt: new Date().toISOString(),
101
+ });
86
102
  })
87
103
  .on(Undo, async (ctx, undo) => {
88
104
  const actorUrl = undo.actorId?.href || "";
@@ -139,7 +155,7 @@ export function registerInboxListeners(inboxChain, options) {
139
155
  const result = await collections.ap_following.findOneAndUpdate(
140
156
  {
141
157
  actorUrl,
142
- source: { $in: ["refollow:sent", "microsub-reader"] },
158
+ source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
143
159
  },
144
160
  {
145
161
  $set: {
@@ -176,7 +192,7 @@ export function registerInboxListeners(inboxChain, options) {
176
192
  const result = await collections.ap_following.findOneAndUpdate(
177
193
  {
178
194
  actorUrl,
179
- source: { $in: ["refollow:sent", "microsub-reader"] },
195
+ source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
180
196
  },
181
197
  {
182
198
  $set: {
@@ -210,17 +226,18 @@ export function registerInboxListeners(inboxChain, options) {
210
226
  if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
211
227
 
212
228
  const actorUrl = like.actorId?.href || "";
213
- let actorName = actorUrl;
229
+ let actorObj;
214
230
  try {
215
- const actorObj = await like.getActor();
216
- actorName =
217
- actorObj?.name?.toString() ||
218
- actorObj?.preferredUsername?.toString() ||
219
- actorUrl;
231
+ actorObj = await like.getActor();
220
232
  } catch {
221
- /* actor not dereferenceable — use URL */
233
+ actorObj = null;
222
234
  }
223
235
 
236
+ const actorName =
237
+ actorObj?.name?.toString() ||
238
+ actorObj?.preferredUsername?.toString() ||
239
+ actorUrl;
240
+
224
241
  await logActivity(collections, storeRawActivities, {
225
242
  direction: "inbound",
226
243
  type: "Like",
@@ -229,35 +246,96 @@ export function registerInboxListeners(inboxChain, options) {
229
246
  objectUrl: objectId,
230
247
  summary: `${actorName} liked ${objectId}`,
231
248
  });
249
+
250
+ // Store notification
251
+ const actorInfo = await extractActorInfo(actorObj);
252
+ await addNotification(collections, {
253
+ uid: like.id?.href || `like:${actorUrl}:${objectId}`,
254
+ type: "like",
255
+ actorUrl: actorInfo.url,
256
+ actorName: actorInfo.name,
257
+ actorPhoto: actorInfo.photo,
258
+ actorHandle: actorInfo.handle,
259
+ targetUrl: objectId,
260
+ targetName: "", // Could fetch post title, but not critical
261
+ published: like.published ? new Date(like.published) : new Date(),
262
+ createdAt: new Date().toISOString(),
263
+ });
232
264
  })
233
265
  .on(Announce, async (ctx, announce) => {
234
- // Use .objectId — no remote fetch needed (see Like handler comment)
235
266
  const objectId = announce.objectId?.href || "";
267
+ if (!objectId) return;
236
268
 
237
- // Only log boosts of our own content
269
+ const actorUrl = announce.actorId?.href || "";
238
270
  const pubUrl = collections._publicationUrl;
239
- if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
240
271
 
241
- const actorUrl = announce.actorId?.href || "";
242
- let actorName = actorUrl;
243
- try {
244
- const actorObj = await announce.getActor();
245
- actorName =
272
+ // Dual path logic: Notification vs Timeline
273
+
274
+ // PATH 1: Boost of OUR content → Notification
275
+ if (pubUrl && objectId.startsWith(pubUrl)) {
276
+ let actorObj;
277
+ try {
278
+ actorObj = await announce.getActor();
279
+ } catch {
280
+ actorObj = null;
281
+ }
282
+
283
+ const actorName =
246
284
  actorObj?.name?.toString() ||
247
285
  actorObj?.preferredUsername?.toString() ||
248
286
  actorUrl;
249
- } catch {
250
- /* actor not dereferenceable — use URL */
287
+
288
+ // Log the boost activity
289
+ await logActivity(collections, storeRawActivities, {
290
+ direction: "inbound",
291
+ type: "Announce",
292
+ actorUrl,
293
+ actorName,
294
+ objectUrl: objectId,
295
+ summary: `${actorName} boosted ${objectId}`,
296
+ });
297
+
298
+ // Create notification
299
+ const actorInfo = await extractActorInfo(actorObj);
300
+ await addNotification(collections, {
301
+ uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
302
+ type: "boost",
303
+ actorUrl: actorInfo.url,
304
+ actorName: actorInfo.name,
305
+ actorPhoto: actorInfo.photo,
306
+ actorHandle: actorInfo.handle,
307
+ targetUrl: objectId,
308
+ targetName: "", // Could fetch post title, but not critical
309
+ published: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
310
+ createdAt: new Date().toISOString(),
311
+ });
312
+
313
+ // Don't return — fall through to check if actor is also followed
251
314
  }
252
315
 
253
- await logActivity(collections, storeRawActivities, {
254
- direction: "inbound",
255
- type: "Announce",
256
- actorUrl,
257
- actorName,
258
- objectUrl: objectId,
259
- summary: `${actorName} boosted ${objectId}`,
260
- });
316
+ // PATH 2: Boost from someone we follow → Timeline (store original post)
317
+ const following = await collections.ap_following.findOne({ actorUrl });
318
+ if (following) {
319
+ try {
320
+ // Fetch the original object being boosted
321
+ const object = await announce.getObject();
322
+ if (!object) return;
323
+
324
+ // Get booster actor info
325
+ const boosterActor = await announce.getActor();
326
+ const boosterInfo = await extractActorInfo(boosterActor);
327
+
328
+ // Extract and store with boost metadata
329
+ const timelineItem = await extractObjectData(object, {
330
+ boostedBy: boosterInfo,
331
+ boostedAt: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
332
+ });
333
+
334
+ await addTimelineItem(collections, timelineItem);
335
+ } catch (error) {
336
+ console.error("Failed to store boosted timeline item:", error);
337
+ }
338
+ }
261
339
  })
262
340
  .on(Create, async (ctx, create) => {
263
341
  let object;
@@ -292,6 +370,7 @@ export function registerInboxListeners(inboxChain, options) {
292
370
  }
293
371
 
294
372
  // Log replies to our posts (existing behavior for conversations)
373
+ const pubUrl = collections._publicationUrl;
295
374
  if (inReplyTo) {
296
375
  const content = object.content?.toString() || "";
297
376
  await logActivity(collections, storeRawActivities, {
@@ -304,21 +383,86 @@ export function registerInboxListeners(inboxChain, options) {
304
383
  content,
305
384
  summary: `${actorName} replied to ${inReplyTo}`,
306
385
  });
386
+
387
+ // Create notification if reply is to one of OUR posts
388
+ if (pubUrl && inReplyTo.startsWith(pubUrl)) {
389
+ const actorInfo = await extractActorInfo(actorObj);
390
+ const rawHtml = object.content?.toString() || "";
391
+ const contentHtml = sanitizeContent(rawHtml);
392
+ const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
393
+
394
+ await addNotification(collections, {
395
+ uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
396
+ type: "reply",
397
+ actorUrl: actorInfo.url,
398
+ actorName: actorInfo.name,
399
+ actorPhoto: actorInfo.photo,
400
+ actorHandle: actorInfo.handle,
401
+ targetUrl: inReplyTo,
402
+ targetName: "",
403
+ content: {
404
+ text: contentText,
405
+ html: contentHtml,
406
+ },
407
+ published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
408
+ createdAt: new Date().toISOString(),
409
+ });
410
+ }
411
+ }
412
+
413
+ // Check for mentions of our actor
414
+ if (object.tag) {
415
+ const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
416
+ const ourActorUrl = ctx.getActorUri(handle).href;
417
+
418
+ for (const tag of tags) {
419
+ if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
420
+ const actorInfo = await extractActorInfo(actorObj);
421
+ const rawMentionHtml = object.content?.toString() || "";
422
+ const mentionHtml = sanitizeContent(rawMentionHtml);
423
+ const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
424
+
425
+ await addNotification(collections, {
426
+ uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
427
+ type: "mention",
428
+ actorUrl: actorInfo.url,
429
+ actorName: actorInfo.name,
430
+ actorPhoto: actorInfo.photo,
431
+ actorHandle: actorInfo.handle,
432
+ content: {
433
+ text: contentText,
434
+ html: mentionHtml,
435
+ },
436
+ published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
437
+ createdAt: new Date().toISOString(),
438
+ });
439
+
440
+ break; // Only create one mention notification per post
441
+ }
442
+ }
443
+ }
444
+
445
+ // Store timeline items from accounts we follow (native storage)
446
+ const following = await collections.ap_following.findOne({ actorUrl });
447
+ if (following) {
448
+ try {
449
+ const timelineItem = await extractObjectData(object);
450
+ await addTimelineItem(collections, timelineItem);
451
+ } catch (error) {
452
+ // Log extraction errors but don't fail the entire handler
453
+ console.error("Failed to store timeline item:", error);
454
+ }
307
455
  }
308
456
 
309
- // Store timeline items from accounts we follow
310
- await storeTimelineItem(collections, {
311
- actorUrl,
312
- actorName,
313
- actorObj,
314
- object,
315
- inReplyTo,
316
- });
317
457
  })
318
458
  .on(Delete, async (ctx, del) => {
319
459
  const objectId = del.objectId?.href || "";
320
460
  if (objectId) {
461
+ // Remove from activity log
321
462
  await collections.ap_activities.deleteMany({ objectUrl: objectId });
463
+
464
+ // Remove from timeline
465
+ await deleteTimelineItem(collections, objectId);
322
466
  }
323
467
  })
324
468
  .on(Move, async (ctx, move) => {
@@ -343,7 +487,44 @@ export function registerInboxListeners(inboxChain, options) {
343
487
  });
344
488
  })
345
489
  .on(Update, async (ctx, update) => {
346
- // Remote actor updated their profile refresh stored follower data
490
+ // Update can be for a profile OR for a post (edited content)
491
+
492
+ // Try to get the object being updated
493
+ let object;
494
+ try {
495
+ object = await update.getObject();
496
+ } catch {
497
+ object = null;
498
+ }
499
+
500
+ // PATH 1: If object is a Note/Article → Update timeline item content
501
+ if (object && (object instanceof Note || object.type === "Article")) {
502
+ const objectUrl = object.id?.href || "";
503
+ if (objectUrl) {
504
+ try {
505
+ // Extract updated content
506
+ const contentHtml = object.content?.toString() || "";
507
+ const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
508
+
509
+ const updates = {
510
+ content: {
511
+ text: contentText,
512
+ html: contentHtml,
513
+ },
514
+ name: object.name?.toString() || "",
515
+ summary: object.summary?.toString() || "",
516
+ sensitive: object.sensitive || false,
517
+ };
518
+
519
+ await updateTimelineItem(collections, objectUrl, updates);
520
+ } catch (error) {
521
+ console.error("Failed to update timeline item:", error);
522
+ }
523
+ }
524
+ return;
525
+ }
526
+
527
+ // PATH 2: Otherwise, assume profile update — refresh stored follower data
347
528
  const actorObj = await update.getActor();
348
529
  const actorUrl = actorObj?.id?.href || "";
349
530
  if (!actorUrl) return;
@@ -397,180 +578,3 @@ async function logActivity(collections, storeRaw, record, rawJson) {
397
578
  );
398
579
  }
399
580
 
400
- // Cached ActivityPub channel ObjectId
401
- let _apChannelId = null;
402
-
403
- /**
404
- * Look up (or auto-create) the ActivityPub channel's ObjectId.
405
- * Cached after first successful call.
406
- *
407
- * The channel is created with `userId: "default"` so it appears in the
408
- * Microsub reader UI alongside user-created channels.
409
- *
410
- * @param {object} collections - MongoDB collections
411
- * @returns {Promise<import("mongodb").ObjectId|null>}
412
- */
413
- async function getApChannelId(collections) {
414
- if (_apChannelId) return _apChannelId;
415
- if (!collections.microsub_channels) return null;
416
-
417
- let channel = await collections.microsub_channels.findOne({
418
- uid: "activitypub",
419
- });
420
-
421
- if (!channel) {
422
- // Auto-create the channel with the same fields the Microsub plugin uses
423
- const maxOrderDoc = await collections.microsub_channels
424
- .find({ userId: "default" })
425
- .sort({ order: -1 })
426
- .limit(1)
427
- .toArray();
428
- const order = maxOrderDoc.length > 0 ? maxOrderDoc[0].order + 1 : 0;
429
-
430
- const doc = {
431
- uid: "activitypub",
432
- name: "Fediverse",
433
- userId: "default",
434
- order,
435
- settings: { excludeTypes: [], excludeRegex: null },
436
- createdAt: new Date().toISOString(),
437
- updatedAt: new Date().toISOString(),
438
- };
439
- await collections.microsub_channels.insertOne(doc);
440
- channel = doc;
441
- console.info("[ActivityPub] Auto-created Microsub channel 'Fediverse'");
442
- } else if (!channel.userId) {
443
- // Fix existing channel missing userId (created by earlier version)
444
- await collections.microsub_channels.updateOne(
445
- { _id: channel._id },
446
- { $set: { userId: "default" } },
447
- );
448
- console.info("[ActivityPub] Fixed Microsub channel: set userId to 'default'");
449
- }
450
-
451
- _apChannelId = channel._id;
452
- return _apChannelId;
453
- }
454
-
455
- /**
456
- * Store a Create activity as a Microsub timeline item if the actor
457
- * is someone we follow. Skips gracefully if the Microsub plugin
458
- * isn't loaded or the AP channel doesn't exist yet.
459
- *
460
- * @param {object} collections - MongoDB collections
461
- * @param {object} params
462
- * @param {string} params.actorUrl - Actor URL
463
- * @param {string} params.actorName - Actor display name
464
- * @param {object} params.actorObj - Fedify actor object
465
- * @param {object} params.object - Fedify Note/Article object
466
- * @param {string|null} params.inReplyTo - URL this is a reply to (if any)
467
- */
468
- async function storeTimelineItem(
469
- collections,
470
- { actorUrl, actorName, actorObj, object, inReplyTo },
471
- ) {
472
- // Skip if Microsub plugin not loaded
473
- if (!collections.microsub_items || !collections.microsub_channels) return;
474
-
475
- // Only store posts from accounts we follow
476
- const following = await collections.ap_following.findOne({ actorUrl });
477
- if (!following) return;
478
-
479
- const channelId = await getApChannelId(collections);
480
- if (!channelId) return;
481
-
482
- const objectUrl = object.id?.href || "";
483
- if (!objectUrl) return;
484
-
485
- // Extract content
486
- const contentHtml = object.content?.toString() || "";
487
- const contentText = contentHtml.replace(/<[^>]*>/g, "").trim();
488
-
489
- // Name (usually only on Article, not Note)
490
- const name = object.name?.toString() || undefined;
491
- const summary = object.summary?.toString() || undefined;
492
-
493
- // Published date — Fedify returns Temporal.Instant
494
- let published;
495
- if (object.published) {
496
- try {
497
- published = new Date(Number(object.published.epochMilliseconds));
498
- } catch {
499
- published = new Date();
500
- }
501
- }
502
-
503
- // Author avatar
504
- let authorPhoto = "";
505
- try {
506
- if (actorObj.icon) {
507
- const iconObj = await actorObj.icon;
508
- authorPhoto = iconObj?.url?.href || "";
509
- }
510
- } catch {
511
- /* remote fetch may fail */
512
- }
513
-
514
- // Tags / categories
515
- const category = [];
516
- try {
517
- for await (const tag of object.getTags()) {
518
- const tagName = tag.name?.toString();
519
- if (tagName) category.push(tagName.replace(/^#/, ""));
520
- }
521
- } catch {
522
- /* ignore */
523
- }
524
-
525
- // Attachments (photos, videos, audio)
526
- const photo = [];
527
- const video = [];
528
- const audio = [];
529
- try {
530
- for await (const att of object.getAttachments()) {
531
- const mediaType = att.mediaType?.toString() || "";
532
- const url = att.url?.href || att.id?.href || "";
533
- if (!url) continue;
534
- if (mediaType.startsWith("image/")) photo.push(url);
535
- else if (mediaType.startsWith("video/")) video.push(url);
536
- else if (mediaType.startsWith("audio/")) audio.push(url);
537
- }
538
- } catch {
539
- /* ignore */
540
- }
541
-
542
- const item = {
543
- channelId,
544
- feedId: null,
545
- uid: objectUrl,
546
- type: "entry",
547
- url: objectUrl,
548
- name,
549
- content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
550
- summary,
551
- published: published || new Date(),
552
- author: {
553
- name: actorName,
554
- url: actorUrl,
555
- photo: authorPhoto,
556
- },
557
- category,
558
- photo,
559
- video,
560
- audio,
561
- inReplyTo: inReplyTo ? [inReplyTo] : [],
562
- source: {
563
- type: "activitypub",
564
- actorUrl,
565
- },
566
- readBy: [],
567
- createdAt: new Date().toISOString(),
568
- };
569
-
570
- // Atomic upsert — prevents duplicates without a separate check+insert
571
- await collections.microsub_items.updateOne(
572
- { channelId, uid: objectUrl },
573
- { $setOnInsert: item },
574
- { upsert: true },
575
- );
576
- }
@@ -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
+ }