@rmdes/indiekit-endpoint-microsub 1.0.64 → 1.0.65

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.
@@ -119,24 +119,6 @@ export async function setCache(redis, key, value, ttl = 300) {
119
119
  }
120
120
  }
121
121
 
122
- /**
123
- * Delete value from cache
124
- * @param {object} redis - Redis client
125
- * @param {string} key - Cache key
126
- * @returns {Promise<void>}
127
- */
128
- export async function deleteCache(redis, key) {
129
- if (!redis) {
130
- return;
131
- }
132
-
133
- try {
134
- await redis.del(nsKey(key));
135
- } catch {
136
- // Ignore cache errors
137
- }
138
- }
139
-
140
122
  /**
141
123
  * Publish event to channel
142
124
  * @param {object} redis - Redis client
@@ -185,16 +167,3 @@ export async function subscribeToChannel(redis, channel, callback) {
185
167
  }
186
168
  }
187
169
 
188
- /**
189
- * Cleanup Redis connection on shutdown
190
- */
191
- export async function closeRedis() {
192
- if (redisClient) {
193
- try {
194
- await redisClient.quit();
195
- redisClient = undefined;
196
- } catch {
197
- // Ignore cleanup errors
198
- }
199
- }
200
- }
@@ -82,5 +82,3 @@ export async function unblock(request, response) {
82
82
 
83
83
  response.json({ result: "ok" });
84
84
  }
85
-
86
- export const blockController = { list, block, unblock };
@@ -132,5 +132,3 @@ export async function get(request, response) {
132
132
  settings: channel.settings,
133
133
  });
134
134
  }
135
-
136
- export const channelsController = { list, action, get };
@@ -53,5 +53,3 @@ export async function stream(request, response) {
53
53
  removeClient(response);
54
54
  });
55
55
  }
56
-
57
- export const eventsController = { stream };
@@ -163,5 +163,3 @@ export async function unfollow(request, response) {
163
163
 
164
164
  response.json({ result: "ok" });
165
165
  }
166
-
167
- export const followController = { list, follow, unfollow };
@@ -121,5 +121,3 @@ export async function unmute(request, response) {
121
121
 
122
122
  response.json({ result: "ok" });
123
123
  }
124
-
125
- export const muteController = { list, mute, unmute };
@@ -63,5 +63,3 @@ export async function preview(request, response) {
63
63
  const previewData = await fetchPreview(url);
64
64
  response.json(previewData);
65
65
  }
66
-
67
- export const previewController = { get, preview };
@@ -1,45 +1,9 @@
1
1
  /**
2
- * Reader controller - barrel re-export
2
+ * Reader controller aggregator. Bundles handlers from sibling modules into the
3
+ * `readerController` object consumed by route wiring in the plugin root.
3
4
  * @module controllers/reader
4
5
  */
5
6
 
6
- export {
7
- index,
8
- channels,
9
- newChannel,
10
- createChannelAction,
11
- channel,
12
- channelHtml,
13
- settings,
14
- updateSettings,
15
- deleteChannelAction,
16
- } from "./channel.js";
17
-
18
- export {
19
- feeds,
20
- addFeed,
21
- removeFeed,
22
- feedDetails,
23
- editFeedForm,
24
- updateFeedUrl,
25
- rediscoverFeed,
26
- refreshFeed,
27
- } from "./feed.js";
28
-
29
- export {
30
- timeline,
31
- timelineHtml,
32
- markAllRead,
33
- markViewRead,
34
- item,
35
- } from "./timeline.js";
36
-
37
- export { compose, submitCompose } from "./compose.js";
38
-
39
- export { searchPage, searchFeeds, subscribe } from "./search.js";
40
-
41
- export { deck, deckSettings, saveDeckSettings } from "./deck.js";
42
-
43
7
  import {
44
8
  index,
45
9
  channels,
@@ -145,5 +145,3 @@ export async function search(request, response) {
145
145
  return response.json({ results: [] });
146
146
  }
147
147
  }
148
-
149
- export const searchController = { discover, search };
@@ -150,5 +150,3 @@ export async function action(request, response) {
150
150
  }
151
151
  }
152
152
  }
153
-
154
- export const timelineController = { get, action };
@@ -78,7 +78,7 @@ export async function discoverAndValidateFeeds(url) {
78
78
  * @param {Array} feeds - Array of feed objects
79
79
  * @returns {Array} Filtered array of main content feeds
80
80
  */
81
- export function filterMainFeeds(feeds) {
81
+ function filterMainFeeds(feeds) {
82
82
  return feeds.filter((feed) => feed.valid && !feed.isCommentsFeed);
83
83
  }
84
84
 
@@ -30,7 +30,7 @@ export function generateItemUid(feedUrl, itemId) {
30
30
  * @param {string|Date} dateInput - Date string or Date object
31
31
  * @returns {Date|undefined} Parsed Date or undefined if invalid
32
32
  */
33
- export function parseDate(dateInput) {
33
+ function parseDate(dateInput) {
34
34
  if (!dateInput) {
35
35
  return;
36
36
  }
@@ -108,7 +108,7 @@ export function extractPhotoUrl(photo) {
108
108
  * @param {object|string} value - URL string or object with url/value property
109
109
  * @returns {string|undefined} URL string
110
110
  */
111
- export function extractUrl(value) {
111
+ function extractUrl(value) {
112
112
  if (!value) {
113
113
  return;
114
114
  }
@@ -98,7 +98,7 @@ const ALLOWED_TYPES = new Set([
98
98
  * @param {string} url - Original image URL
99
99
  * @returns {string} URL-safe hash
100
100
  */
101
- export function hashUrl(url) {
101
+ function hashUrl(url) {
102
102
  return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
103
103
  }
104
104
 
@@ -107,7 +107,7 @@ export function hashUrl(url) {
107
107
  * @param {string} url - Original image URL
108
108
  * @returns {string} HMAC hex signature (16 chars)
109
109
  */
110
- export function signProxyUrl(url) {
110
+ function signProxyUrl(url) {
111
111
  const secret = process.env.SECRET || "microsub-default-secret";
112
112
  return crypto
113
113
  .createHmac("sha256", secret)
@@ -122,7 +122,7 @@ export function signProxyUrl(url) {
122
122
  * @param {string} sig - Submitted signature
123
123
  * @returns {boolean} Whether signature is valid
124
124
  */
125
- export function verifyProxySignature(url, sig) {
125
+ function verifyProxySignature(url, sig) {
126
126
  if (!sig) return false;
127
127
  const expected = signProxyUrl(url);
128
128
  // Constant-time comparison
@@ -136,7 +136,7 @@ export function verifyProxySignature(url, sig) {
136
136
  * @param {string} originalUrl - Original image URL
137
137
  * @returns {string} Proxied URL
138
138
  */
139
- export function getProxiedUrl(baseUrl, originalUrl) {
139
+ function getProxiedUrl(baseUrl, originalUrl) {
140
140
  if (!originalUrl || !baseUrl) {
141
141
  return originalUrl;
142
142
  }
@@ -203,7 +203,7 @@ export function proxyItemImages(item, baseUrl) {
203
203
  * @param {string} url - Image URL to fetch
204
204
  * @returns {Promise<object|null>} Cached image data or null
205
205
  */
206
- export async function fetchImage(redis, url) {
206
+ async function fetchImage(redis, url) {
207
207
  // Block private/internal URLs (defense-in-depth)
208
208
  if (await isPrivateUrl(url)) {
209
209
  console.error(`[Microsub] Media proxy blocked private URL: ${url}`);
@@ -118,13 +118,3 @@ export async function refreshFeedNow(application, feedId) {
118
118
  return processFeed(application, feed);
119
119
  }
120
120
 
121
- /**
122
- * Get scheduler status
123
- * @returns {object} Scheduler status
124
- */
125
- export function getSchedulerStatus() {
126
- return {
127
- running: !!schedulerInterval,
128
- processing: isRunning,
129
- };
130
- }
@@ -23,22 +23,24 @@ const MAX_TIER = 10;
23
23
  const DEFAULT_TIER = 1;
24
24
 
25
25
  /**
26
- * Get polling interval for a tier in milliseconds
26
+ * Get polling interval for a tier in milliseconds.
27
+ * Internal helper for getNextFetchTime.
27
28
  * @param {number} tier - Polling tier (0-10)
28
29
  * @returns {number} Interval in milliseconds
29
30
  */
30
- export function getIntervalForTier(tier) {
31
+ function getIntervalForTier(tier) {
31
32
  const clampedTier = Math.max(MIN_TIER, Math.min(MAX_TIER, tier));
32
33
  const minutes = Math.pow(2, clampedTier);
33
34
  return minutes * 60 * 1000;
34
35
  }
35
36
 
36
37
  /**
37
- * Get next fetch time based on tier
38
+ * Get next fetch time based on tier.
39
+ * Internal helper for calculateNewTier.
38
40
  * @param {number} tier - Polling tier
39
41
  * @returns {Date} Next fetch time
40
42
  */
41
- export function getNextFetchTime(tier) {
43
+ function getNextFetchTime(tier) {
42
44
  const interval = getIntervalForTier(tier);
43
45
  return new Date(Date.now() + interval);
44
46
  }
@@ -90,50 +92,3 @@ export function calculateNewTier(options) {
90
92
  };
91
93
  }
92
94
 
93
- /**
94
- * Get initial tier for a new feed subscription
95
- * @returns {object} Initial tier settings
96
- */
97
- export function getInitialTier() {
98
- return {
99
- tier: MIN_TIER, // Start at tier 0 for immediate first fetch
100
- consecutiveUnchanged: 0,
101
- nextFetchAt: new Date(), // Fetch immediately
102
- };
103
- }
104
-
105
- /**
106
- * Determine if a feed is due for fetching
107
- * @param {object} feed - Feed document
108
- * @returns {boolean} Whether the feed should be fetched
109
- */
110
- export function isDueForFetch(feed) {
111
- if (!feed.nextFetchAt) {
112
- return true;
113
- }
114
-
115
- return new Date(feed.nextFetchAt) <= new Date();
116
- }
117
-
118
- /**
119
- * Get human-readable description of polling interval
120
- * @param {number} tier - Polling tier
121
- * @returns {string} Description
122
- */
123
- export function getTierDescription(tier) {
124
- const minutes = Math.pow(2, tier);
125
-
126
- if (minutes < 60) {
127
- return `every ${minutes} minute${minutes === 1 ? "" : "s"}`;
128
- }
129
-
130
- const hours = minutes / 60;
131
- if (hours < 24) {
132
- return `every ${hours.toFixed(1)} hour${hours === 1 ? "" : "s"}`;
133
- }
134
-
135
- const days = hours / 24;
136
- return `every ${days.toFixed(1)} day${days === 1 ? "" : "s"}`;
137
- }
138
-
139
- export { MIN_TIER, MAX_TIER, DEFAULT_TIER };
@@ -84,18 +84,6 @@ export function subscribeClient(response, channelId) {
84
84
  }
85
85
  }
86
86
 
87
- /**
88
- * Unsubscribe a client from a channel
89
- * @param {object} response - Express response object
90
- * @param {string} channelId - Channel ID
91
- */
92
- export function unsubscribeClient(response, channelId) {
93
- const client = clients.get(response);
94
- if (client) {
95
- client.channels.delete(channelId);
96
- }
97
- }
98
-
99
87
  /**
100
88
  * Send an event to a specific client
101
89
  * @param {object} response - Express response object
@@ -113,26 +101,13 @@ export function sendEvent(response, event, data) {
113
101
  }
114
102
 
115
103
  /**
116
- * Broadcast an event to all clients subscribed to a channel
117
- * @param {string} channelId - Channel ID
118
- * @param {string} event - Event name
119
- * @param {object} data - Event data
120
- */
121
- export function broadcastToChannel(channelId, event, data) {
122
- for (const client of clients.values()) {
123
- if (client.channels.has(channelId)) {
124
- sendEvent(client.response, event, data);
125
- }
126
- }
127
- }
128
-
129
- /**
130
- * Broadcast an event to all clients for a user
104
+ * Broadcast an event to all clients for a user.
105
+ * Internal helper only invoked by the Redis fan-out in handleRedisEvent.
131
106
  * @param {string} userId - User ID
132
107
  * @param {string} event - Event name
133
108
  * @param {object} data - Event data
134
109
  */
135
- export function broadcastToUser(userId, event, data) {
110
+ function broadcastToUser(userId, event, data) {
136
111
  for (const client of clients.values()) {
137
112
  if (client.userId === userId) {
138
113
  sendEvent(client.response, event, data);
@@ -140,17 +115,6 @@ export function broadcastToUser(userId, event, data) {
140
115
  }
141
116
  }
142
117
 
143
- /**
144
- * Broadcast an event to all connected clients
145
- * @param {string} event - Event name
146
- * @param {object} data - Event data
147
- */
148
- export function broadcastToAll(event, data) {
149
- for (const client of clients.values()) {
150
- sendEvent(client.response, event, data);
151
- }
152
- }
153
-
154
118
  /**
155
119
  * Set up Redis subscription for a user
156
120
  * @param {string} userId - User ID
@@ -208,39 +172,3 @@ function handleRedisEvent(userId, data) {
208
172
  }
209
173
  }
210
174
 
211
- /**
212
- * Get broker statistics
213
- * @returns {object} Statistics
214
- */
215
- export function getStats() {
216
- const userCounts = new Map();
217
- for (const client of clients.values()) {
218
- const count = userCounts.get(client.userId) || 0;
219
- userCounts.set(client.userId, count + 1);
220
- }
221
-
222
- return {
223
- totalClients: clients.size,
224
- uniqueUsers: userCounts.size,
225
- userSubscribers: userSubscribers.size,
226
- };
227
- }
228
-
229
- /**
230
- * Clean up all connections
231
- */
232
- export function cleanup() {
233
- for (const client of clients.values()) {
234
- clearInterval(client.pingInterval);
235
- }
236
- clients.clear();
237
-
238
- for (const subscriber of userSubscribers.values()) {
239
- try {
240
- subscriber.quit();
241
- } catch {
242
- // Ignore cleanup errors
243
- }
244
- }
245
- userSubscribers.clear();
246
- }
@@ -29,7 +29,7 @@ const CHANNEL_COLORS = [
29
29
  * @param {number} order - Channel order index
30
30
  * @returns {string} Hex color
31
31
  */
32
- export function getChannelColor(order) {
32
+ function getChannelColor(order) {
33
33
  return CHANNEL_COLORS[Math.abs(order) % CHANNEL_COLORS.length];
34
34
  }
35
35
 
@@ -45,7 +45,7 @@ export function normalizeUrl(url) {
45
45
  * @param {string} url - Feed URL to check
46
46
  * @returns {Promise<object|null>} Existing feed with channel info, or null
47
47
  */
48
- export async function findFeedAcrossChannels(application, url) {
48
+ async function findFeedAcrossChannels(application, url) {
49
49
  const collection = getCollection(application);
50
50
  const normalized = normalizeUrl(url);
51
51
 
@@ -427,19 +427,3 @@ export async function updateFeedStatus(application, id, status) {
427
427
  });
428
428
  }
429
429
 
430
- /**
431
- * Get feeds with errors
432
- * @param {object} application - Indiekit application
433
- * @param {number} [minErrors=3] - Minimum consecutive errors
434
- * @returns {Promise<Array>} Array of feeds with errors
435
- */
436
- export async function getFeedsWithErrors(application, minErrors = 3) {
437
- const collection = getCollection(application);
438
-
439
- return collection
440
- .find({
441
- status: "error",
442
- consecutiveErrors: { $gte: minErrors },
443
- })
444
- .toArray();
445
- }
@@ -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
- }
@@ -323,20 +323,6 @@ export async function getItemById(application, id, userId) {
323
323
  return transformToJf2(item, userId);
324
324
  }
325
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));
338
- }
339
-
340
326
  /**
341
327
  * Remove items from channel
342
328
  * @param {object} application - Indiekit application
@@ -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.65",
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",
@@ -1,109 +0,0 @@
1
- /**
2
- * Read state tracking utilities
3
- * @module storage/read-state
4
- */
5
-
6
- import { markItemsRead, markItemsUnread, getUnreadCount } from "./items-read-state.js";
7
-
8
- /**
9
- * Mark entries as read for a user
10
- * @param {object} application - Indiekit application
11
- * @param {string} channelUid - Channel UID
12
- * @param {Array} entries - Entry IDs to mark as read
13
- * @param {string} userId - User ID
14
- * @returns {Promise<number>} Number of entries marked
15
- */
16
- export async function markRead(application, channelUid, entries, userId) {
17
- const channelsCollection = application.collections.get("microsub_channels");
18
- const channel = await channelsCollection.findOne({ uid: channelUid });
19
-
20
- if (!channel) {
21
- return 0;
22
- }
23
-
24
- return markItemsRead(application, channel._id, entries, userId);
25
- }
26
-
27
- /**
28
- * Mark entries as unread for a user
29
- * @param {object} application - Indiekit application
30
- * @param {string} channelUid - Channel UID
31
- * @param {Array} entries - Entry IDs to mark as unread
32
- * @param {string} userId - User ID
33
- * @returns {Promise<number>} Number of entries marked
34
- */
35
- export async function markUnread(application, channelUid, entries, userId) {
36
- const channelsCollection = application.collections.get("microsub_channels");
37
- const channel = await channelsCollection.findOne({ uid: channelUid });
38
-
39
- if (!channel) {
40
- return 0;
41
- }
42
-
43
- return markItemsUnread(application, channel._id, entries, userId);
44
- }
45
-
46
- /**
47
- * Get unread count for a channel
48
- * @param {object} application - Indiekit application
49
- * @param {string} channelUid - Channel UID
50
- * @param {string} userId - User ID
51
- * @returns {Promise<number>} Unread count
52
- */
53
- export async function getChannelUnreadCount(application, channelUid, userId) {
54
- const channelsCollection = application.collections.get("microsub_channels");
55
- const channel = await channelsCollection.findOne({ uid: channelUid });
56
-
57
- if (!channel) {
58
- return 0;
59
- }
60
-
61
- return getUnreadCount(application, channel._id, userId);
62
- }
63
-
64
- /**
65
- * Get unread counts for all channels
66
- * @param {object} application - Indiekit application
67
- * @param {string} userId - User ID
68
- * @returns {Promise<Map>} Map of channel UID to unread count
69
- */
70
- export async function getAllUnreadCounts(application, userId) {
71
- const channelsCollection = application.collections.get("microsub_channels");
72
- const itemsCollection = application.collections.get("microsub_items");
73
-
74
- // Aggregate unread counts per channel
75
- const pipeline = [
76
- {
77
- $match: {
78
- readBy: { $ne: userId },
79
- },
80
- },
81
- {
82
- $group: {
83
- _id: "$channelId",
84
- count: { $sum: 1 },
85
- },
86
- },
87
- ];
88
-
89
- const results = await itemsCollection.aggregate(pipeline).toArray();
90
-
91
- // Get channel UIDs
92
- const channelIds = results.map((r) => r._id);
93
- const channels = await channelsCollection
94
- .find({ _id: { $in: channelIds } })
95
- .toArray();
96
-
97
- const channelMap = new Map(channels.map((c) => [c._id.toString(), c.uid]));
98
-
99
- // Build result map
100
- const unreadCounts = new Map();
101
- for (const result of results) {
102
- const uid = channelMap.get(result._id.toString());
103
- if (uid) {
104
- unreadCounts.set(uid, result.count);
105
- }
106
- }
107
-
108
- return unreadCounts;
109
- }
@@ -1,129 +0,0 @@
1
- /**
2
- * WebSub hub discovery
3
- * @module websub/discovery
4
- */
5
-
6
- /**
7
- * Discover WebSub hub from HTTP response headers and content
8
- * @param {object} response - Fetch response object
9
- * @param {string} content - Response body content
10
- * @returns {object|undefined} WebSub info { hub, self }
11
- */
12
- export function discoverWebsub(response, content) {
13
- // Try to find hub and self URLs from Link headers first
14
- const linkHeader = response.headers.get("link");
15
- const fromHeaders = linkHeader ? parseLinkHeader(linkHeader) : {};
16
-
17
- // Fall back to content parsing
18
- const fromContent = parseContentForLinks(content);
19
-
20
- const hub = fromHeaders.hub || fromContent.hub;
21
- const self = fromHeaders.self || fromContent.self;
22
-
23
- if (hub) {
24
- return { hub, self };
25
- }
26
-
27
- return;
28
- }
29
-
30
- /**
31
- * Parse Link header for hub and self URLs
32
- * @param {string} linkHeader - Link header value
33
- * @returns {object} { hub, self }
34
- */
35
- function parseLinkHeader(linkHeader) {
36
- const result = {};
37
- const links = linkHeader.split(",");
38
-
39
- for (const link of links) {
40
- const parts = link.trim().split(";");
41
- if (parts.length < 2) continue;
42
-
43
- const urlMatch = parts[0].match(/<([^>]+)>/);
44
- if (!urlMatch) continue;
45
-
46
- const url = urlMatch[1];
47
- const relationship = parts
48
- .slice(1)
49
- .find((p) => p.trim().startsWith("rel="))
50
- ?.match(/rel=["']?([^"'\s;]+)["']?/)?.[1];
51
-
52
- if (relationship === "hub") {
53
- result.hub = url;
54
- } else if (relationship === "self") {
55
- result.self = url;
56
- }
57
- }
58
-
59
- return result;
60
- }
61
-
62
- /**
63
- * Parse content for hub and self URLs (Atom, RSS, HTML)
64
- * @param {string} content - Response body
65
- * @returns {object} { hub, self }
66
- */
67
- function parseContentForLinks(content) {
68
- const result = {};
69
-
70
- // Try HTML <link> elements
71
- const htmlHubMatch = content.match(
72
- /<link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
73
- );
74
- if (htmlHubMatch) {
75
- result.hub = htmlHubMatch[1];
76
- }
77
-
78
- const htmlSelfMatch = content.match(
79
- /<link[^>]+rel=["']?self["']?[^>]+href=["']([^"']+)["']/i,
80
- );
81
- if (htmlSelfMatch) {
82
- result.self = htmlSelfMatch[1];
83
- }
84
-
85
- // Also try the reverse order (href before rel)
86
- if (!result.hub) {
87
- const htmlHubMatch2 = content.match(
88
- /<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?hub["']?/i,
89
- );
90
- if (htmlHubMatch2) {
91
- result.hub = htmlHubMatch2[1];
92
- }
93
- }
94
-
95
- if (!result.self) {
96
- const htmlSelfMatch2 = content.match(
97
- /<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?self["']?/i,
98
- );
99
- if (htmlSelfMatch2) {
100
- result.self = htmlSelfMatch2[1];
101
- }
102
- }
103
-
104
- // Try Atom <link> elements
105
- if (!result.hub) {
106
- const atomHubMatch = content.match(
107
- /<atom:link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
108
- );
109
- if (atomHubMatch) {
110
- result.hub = atomHubMatch[1];
111
- }
112
- }
113
-
114
- return result;
115
- }
116
-
117
- /**
118
- * Check if a hub URL is valid
119
- * @param {string} hubUrl - Hub URL to validate
120
- * @returns {boolean} Whether the URL is valid
121
- */
122
- export function isValidHubUrl(hubUrl) {
123
- try {
124
- const url = new URL(hubUrl);
125
- return url.protocol === "https:" || url.protocol === "http:";
126
- } catch {
127
- return false;
128
- }
129
- }