@rmdes/indiekit-endpoint-microsub 1.0.64 → 1.0.66

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.
@@ -1,117 +1,22 @@
1
1
  /**
2
- * Filter storage operations (mute, block, channel filters)
2
+ * Item filtering: exclude-types and exclude-regex predicates applied during
3
+ * feed ingestion. Used by `lib/polling/processor.js` when deciding whether a
4
+ * newly-parsed item should be stored.
5
+ *
6
+ * Historical note: this module previously also held mute/block storage
7
+ * (microsub_muted / microsub_blocked operations). That code path was abandoned
8
+ * — those collections are managed directly by `lib/controllers/mute.js` and
9
+ * `lib/controllers/block.js` against MongoDB without an intermediate storage
10
+ * helper layer. Only the per-channel exclude filters remain here.
11
+ *
3
12
  * @module storage/filters
4
13
  */
5
14
 
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
15
  /**
111
- * Check if an item passes the excludeTypes filter
16
+ * Check if an item passes the channel.settings.excludeTypes filter.
112
17
  * @param {object} item - Feed item
113
18
  * @param {object} settings - Channel settings
114
- * @returns {boolean} Whether the item passes
19
+ * @returns {boolean} True when the item should be kept
115
20
  */
116
21
  export function passesTypeFilter(item, settings) {
117
22
  if (!settings.excludeTypes || settings.excludeTypes.length === 0) {
@@ -123,10 +28,10 @@ export function passesTypeFilter(item, settings) {
123
28
  }
124
29
 
125
30
  /**
126
- * Check if an item passes the excludeRegex filter
31
+ * Check if an item passes the channel.settings.excludeRegex filter.
127
32
  * @param {object} item - Feed item
128
33
  * @param {object} settings - Channel settings
129
- * @returns {boolean} Whether the item passes
34
+ * @returns {boolean} True when the item should be kept
130
35
  */
131
36
  export function passesRegexFilter(item, settings) {
132
37
  if (!settings.excludeRegex) {
@@ -146,17 +51,26 @@ export function passesRegexFilter(item, settings) {
146
51
 
147
52
  return !regex.test(searchText);
148
53
  } catch {
149
- // Invalid regex, skip filter
54
+ // Invalid regex skip the filter rather than rejecting every item.
150
55
  return true;
151
56
  }
152
57
  }
153
58
 
154
59
  /**
155
- * Detect the interaction type of an item
60
+ * Classify an item by its interaction property. Internal helper for
61
+ * passesTypeFilter — only its symbolic return value (one of "like" | "repost"
62
+ * | "bookmark" | "reply" | "rsvp" | "checkin" | "post") is compared against
63
+ * the excludeTypes list.
64
+ *
65
+ * Note: a similar but stricter classifier exists in `lib/utils/jf2.js` for
66
+ * API-response shaping. The two cannot trivially be merged because this one
67
+ * treats kebab-case keys only and emits a "post" default that the jf2 one
68
+ * doesn't.
69
+ *
156
70
  * @param {object} item - Feed item
157
71
  * @returns {string} Interaction type
158
72
  */
159
- export function detectInteractionType(item) {
73
+ function detectInteractionType(item) {
160
74
  if (item["like-of"] && item["like-of"].length > 0) {
161
75
  return "like";
162
76
  }
@@ -178,88 +92,3 @@ export function detectInteractionType(item) {
178
92
 
179
93
  return "post";
180
94
  }
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
- }
@@ -121,6 +121,73 @@ export function transformToJf2(item, userId) {
121
121
  return jf2;
122
122
  }
123
123
 
124
+ /**
125
+ * Derive a favicon URL from a site or feed URL
126
+ * @param {string} url - Site or feed URL
127
+ * @returns {string|undefined} Favicon URL at the site origin
128
+ */
129
+ function faviconUrl(url) {
130
+ try {
131
+ return new URL(url).origin + "/favicon.ico";
132
+ } catch {
133
+ return;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Enrich jf2 items with source identity (name, photo, url) from their feeds.
139
+ * Read-time join against microsub_feeds so existing items pick up feed
140
+ * metadata (title, channel image, site URL) without a migration, and stay
141
+ * fresh when a feed's metadata improves. Falls back to the site favicon
142
+ * when the feed has no image.
143
+ * @param {object} application - Indiekit application
144
+ * @param {Array<object>} items - jf2 items (mutated in place)
145
+ * @returns {Promise<Array<object>>} The same items, enriched
146
+ */
147
+ export async function enrichItemsWithFeedSource(application, items) {
148
+ const feedIds = [
149
+ ...new Set(items.map((item) => item._feedId).filter(Boolean)),
150
+ ];
151
+ if (feedIds.length === 0) {
152
+ return items;
153
+ }
154
+
155
+ const feeds = await application.collections
156
+ .get("microsub_feeds")
157
+ .find({ _id: { $in: feedIds.map((id) => new ObjectId(id)) } })
158
+ .project({ title: 1, photo: 1, url: 1, siteUrl: 1 })
159
+ .toArray();
160
+ const feedsById = new Map(feeds.map((feed) => [feed._id.toString(), feed]));
161
+
162
+ for (const item of items) {
163
+ const feed = feedsById.get(item._feedId);
164
+ if (!feed) continue;
165
+
166
+ const siteUrl = feed.siteUrl || feed.url;
167
+ item._source = {
168
+ ...item._source,
169
+ name: feed.title || item._source?.name || safeHostname(siteUrl),
170
+ photo: feed.photo || faviconUrl(siteUrl),
171
+ url: siteUrl,
172
+ };
173
+ }
174
+
175
+ return items;
176
+ }
177
+
178
+ /**
179
+ * Extract hostname from a URL, or return the input on parse failure
180
+ * @param {string} url - URL string
181
+ * @returns {string} Hostname
182
+ */
183
+ function safeHostname(url) {
184
+ try {
185
+ return new URL(url).hostname;
186
+ } catch {
187
+ return url;
188
+ }
189
+ }
190
+
124
191
  /**
125
192
  * Add an item to a channel
126
193
  * @param {object} application - Indiekit application
@@ -218,6 +285,7 @@ export async function getTimelineItems(application, channelId, options = {}) {
218
285
 
219
286
  // Transform to jf2 format
220
287
  const jf2Items = items.map((item) => transformToJf2(item, options.userId));
288
+ await enrichItemsWithFeedSource(application, jf2Items);
221
289
 
222
290
  // Generate paging cursors
223
291
  const paging = generatePagingCursors(items, limit, hasMore, options.before);
@@ -282,6 +350,7 @@ export async function getAllTimelineItems(application, options = {}) {
282
350
  }
283
351
 
284
352
  const jf2Items = items.map((item) => transformToJf2(item, options.userId));
353
+ await enrichItemsWithFeedSource(application, jf2Items);
285
354
 
286
355
  const paging = generatePagingCursors(items, limit, hasMore, options.before);
287
356
 
@@ -320,21 +389,9 @@ export async function getItemById(application, id, userId) {
320
389
  return;
321
390
  }
322
391
 
323
- return transformToJf2(item, userId);
324
- }
325
-
326
- /**
327
- * Get items by UIDs
328
- * @param {object} application - Indiekit application
329
- * @param {Array} uids - Array of item UIDs
330
- * @param {string} [userId] - User ID for read state
331
- * @returns {Promise<Array>} Array of jf2 items
332
- */
333
- export async function getItemsByUids(application, uids, userId) {
334
- const collection = getCollection(application);
335
-
336
- const items = await collection.find({ uid: { $in: uids } }).toArray();
337
- return items.map((item) => transformToJf2(item, userId));
392
+ const jf2 = transformToJf2(item, userId);
393
+ await enrichItemsWithFeedSource(application, [jf2]);
394
+ return jf2;
338
395
  }
339
396
 
340
397
  /**
@@ -11,7 +11,7 @@ import { ObjectId } from "mongodb";
11
11
  * @param {string} id - Item ID
12
12
  * @returns {string} Base64-encoded cursor
13
13
  */
14
- export function encodeCursor(timestamp, id) {
14
+ function encodeCursor(timestamp, id) {
15
15
  const data = {
16
16
  t: timestamp instanceof Date ? timestamp.toISOString() : timestamp,
17
17
  i: id.toString(),
@@ -24,7 +24,7 @@ export function encodeCursor(timestamp, id) {
24
24
  * @param {string} cursor - Base64-encoded cursor
25
25
  * @returns {object|null} Decoded cursor with timestamp and id
26
26
  */
27
- export function decodeCursor(cursor) {
27
+ function decodeCursor(cursor) {
28
28
  if (!cursor) return;
29
29
 
30
30
  try {
@@ -133,15 +133,8 @@ export function generatePagingCursors(items, limit, hasMore, before) {
133
133
  return paging;
134
134
  }
135
135
 
136
- /**
137
- * Default pagination limit
138
- */
139
- export const DEFAULT_LIMIT = 20;
140
-
141
- /**
142
- * Maximum pagination limit
143
- */
144
- export const MAX_LIMIT = 100;
136
+ const DEFAULT_LIMIT = 20;
137
+ const MAX_LIMIT = 100;
145
138
 
146
139
  /**
147
140
  * Parse and validate limit parameter
@@ -23,16 +23,6 @@ export const VALID_ACTIONS = [
23
23
  "events",
24
24
  ];
25
25
 
26
- /**
27
- * Valid channel methods
28
- */
29
- export const VALID_CHANNEL_METHODS = ["delete", "order"];
30
-
31
- /**
32
- * Valid timeline methods
33
- */
34
- export const VALID_TIMELINE_METHODS = ["mark_read", "mark_read_source", "mark_unread", "remove"];
35
-
36
26
  /**
37
27
  * Valid exclude types for channel filtering
38
28
  */
@@ -98,87 +98,6 @@ export async function processWebmention(application, source, target, userId) {
98
98
  };
99
99
  }
100
100
 
101
- /**
102
- * Delete a webmention (when source no longer links to target)
103
- * @param {object} application - Indiekit application
104
- * @param {string} source - Source URL
105
- * @param {string} target - Target URL
106
- * @returns {Promise<boolean>} Whether deletion was successful
107
- */
108
- export async function deleteWebmention(application, source, target) {
109
- const collection = getCollection(application);
110
- const result = await collection.deleteOne({ source, target });
111
- return result.deletedCount > 0;
112
- }
113
-
114
- /**
115
- * Get notifications for a user
116
- * @param {object} application - Indiekit application
117
- * @param {string} userId - User ID
118
- * @param {object} options - Query options
119
- * @returns {Promise<Array>} Array of notifications
120
- */
121
- export async function getNotifications(application, userId, options = {}) {
122
- const collection = getCollection(application);
123
- const { limit = 20, unreadOnly = false } = options;
124
-
125
- const query = { userId };
126
- if (unreadOnly) {
127
- query.readBy = { $ne: userId };
128
- }
129
-
130
- /* eslint-disable unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB cursor methods */
131
- const notifications = await collection
132
- .find(query)
133
- .sort({ published: -1 })
134
- .limit(limit)
135
- .toArray();
136
- /* eslint-enable unicorn/no-array-callback-reference, unicorn/no-array-sort */
137
-
138
- return notifications.map((n) => transformNotification(n, userId));
139
- }
140
-
141
- /**
142
- * Mark notifications as read
143
- * @param {object} application - Indiekit application
144
- * @param {string} userId - User ID
145
- * @param {Array} ids - Notification IDs to mark as read
146
- * @returns {Promise<number>} Number of notifications updated
147
- */
148
- export async function markNotificationsRead(application, userId, ids) {
149
- const collection = getCollection(application);
150
- const { ObjectId } = await import("mongodb");
151
-
152
- const objectIds = ids.map((id) => {
153
- try {
154
- return new ObjectId(id);
155
- } catch {
156
- return id;
157
- }
158
- });
159
-
160
- const result = await collection.updateMany(
161
- { _id: { $in: objectIds } },
162
- { $addToSet: { readBy: userId } },
163
- );
164
-
165
- return result.modifiedCount;
166
- }
167
-
168
- /**
169
- * Get unread notification count
170
- * @param {object} application - Indiekit application
171
- * @param {string} userId - User ID
172
- * @returns {Promise<number>} Unread count
173
- */
174
- export async function getUnreadNotificationCount(application, userId) {
175
- const collection = getCollection(application);
176
- return collection.countDocuments({
177
- userId,
178
- readBy: { $ne: userId },
179
- });
180
- }
181
-
182
101
  /**
183
102
  * Transform notification to API format
184
103
  * @param {object} notification - Database notification
@@ -200,15 +119,3 @@ function transformNotification(notification, userId) {
200
119
  };
201
120
  }
202
121
 
203
- /**
204
- * Create indexes for notifications
205
- * @param {object} application - Indiekit application
206
- * @returns {Promise<void>}
207
- */
208
- export async function createNotificationIndexes(application) {
209
- const collection = getCollection(application);
210
-
211
- await collection.createIndex({ userId: 1, published: -1 });
212
- await collection.createIndex({ source: 1, target: 1 });
213
- await collection.createIndex({ userId: 1, readBy: 1 });
214
- }
@@ -165,23 +165,6 @@ export function verifySignature(signature, body, secret) {
165
165
  }
166
166
  }
167
167
 
168
- /**
169
- * Check if a WebSub subscription is about to expire
170
- * @param {object} feed - Feed document
171
- * @param {number} [thresholdSeconds] - Seconds before expiry to consider "expiring"
172
- * @returns {boolean} Whether subscription is expiring soon
173
- */
174
- export function isSubscriptionExpiring(feed, thresholdSeconds = 86_400) {
175
- if (!feed.websub?.expiresAt) {
176
- return false;
177
- }
178
-
179
- const expiresAt = new Date(feed.websub.expiresAt);
180
- const threshold = new Date(Date.now() + thresholdSeconds * 1000);
181
-
182
- return expiresAt <= threshold;
183
- }
184
-
185
168
  /**
186
169
  * Get callback URL for a feed
187
170
  * @param {string} baseUrl - Base URL of the Microsub endpoint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -40,12 +40,9 @@
40
40
  "@rmdes/indiekit-startup-gate": "^1.0.0",
41
41
  "@indiekit/frontend": "^1.0.0-beta.25",
42
42
  "@indiekit/util": "^1.0.0-beta.25",
43
- "debug": "^4.3.2",
44
43
  "express": "^5.0.0",
45
44
  "feedparser": "^2.2.10",
46
- "htmlparser2": "^9.0.0",
47
45
  "ioredis": "^5.3.0",
48
- "luxon": "^3.4.0",
49
46
  "microformats-parser": "^2.0.0",
50
47
  "express-rate-limit": "^7.0.0",
51
48
  "safe-regex2": "^4.0.0",
@@ -18,7 +18,10 @@
18
18
  {% endif %}
19
19
  <div class="ms-item-card-compact__meta">
20
20
  {% if item._source %}
21
- <span class="ms-item-card-compact__source">{{ item._source.name or item._source.url }}</span>
21
+ <span class="ms-item-card-compact__source">
22
+ {%- if item._source.photo %}<img src="{{ item._source.photo }}" alt="" class="ms-item-card-compact__favicon" width="16" height="16" loading="lazy"> {% endif -%}
23
+ {{- item._source.name or item._source.url -}}
24
+ </span>
22
25
  {% elif item.author %}
23
26
  <span class="ms-item-card-compact__source">{{ item.author.name }}</span>
24
27
  {% endif %}
@@ -50,25 +50,27 @@
50
50
  {% endif %}
51
51
 
52
52
  <a href="{{ baseUrl }}/item/{{ item._id }}" class="ms-item-card__link">
53
- {# Author #}
54
- {% if item.author %}
53
+ {# Author / source identity — prefer item author, fall back to feed source #}
54
+ {% set identityPhoto = item.author.photo or item._source.photo %}
55
+ {% set identityName = item.author.name or item._source.name %}
56
+ {% if identityName or identityPhoto %}
55
57
  <div class="ms-item-card__author">
56
58
  <div class="ms-item-card__avatar-wrap" data-avatar-fallback>
57
- {% if item.author.photo %}
58
- <img src="{{ item.author.photo }}"
59
+ {% if identityPhoto %}
60
+ <img src="{{ identityPhoto }}"
59
61
  alt=""
60
62
  class="ms-item-card__author-photo"
61
63
  width="40"
62
64
  height="40"
63
65
  loading="lazy">
64
66
  {% endif %}
65
- <span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
67
+ <span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ identityName[0] | upper if identityName else "?" }}</span>
66
68
  </div>
67
69
  <div class="ms-item-card__author-info">
68
- <span class="ms-item-card__author-name">{{ item.author.name or "Unknown" }}</span>
69
- {% if item._source %}
70
+ <span class="ms-item-card__author-name">{{ identityName or "Unknown" }}</span>
71
+ {% if item._source and (item._source.name or item._source.url) and item._source.name != identityName %}
70
72
  <span class="ms-item-card__source">{{ item._source.name or item._source.url }}</span>
71
- {% elif item.author.url %}
73
+ {% elif item.author.url and item.author.name %}
72
74
  <span class="ms-item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
73
75
  {% endif %}
74
76
  </div>