@rmdes/indiekit-endpoint-microsub 1.0.63 → 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.
Files changed (38) hide show
  1. package/lib/cache/redis.js +0 -31
  2. package/lib/controllers/block.js +0 -2
  3. package/lib/controllers/channels.js +0 -2
  4. package/lib/controllers/events.js +0 -2
  5. package/lib/controllers/follow.js +0 -2
  6. package/lib/controllers/mute.js +0 -2
  7. package/lib/controllers/preview.js +0 -2
  8. package/lib/controllers/reader/channel.js +0 -1
  9. package/lib/controllers/reader/index.js +2 -38
  10. package/lib/controllers/search.js +0 -2
  11. package/lib/controllers/timeline.js +0 -2
  12. package/lib/feeds/discovery.js +1 -1
  13. package/lib/feeds/normalizer.js +2 -2
  14. package/lib/media/proxy.js +5 -5
  15. package/lib/polling/scheduler.js +4 -14
  16. package/lib/polling/tier.js +6 -51
  17. package/lib/realtime/broker.js +3 -75
  18. package/lib/storage/channels.js +1 -1
  19. package/lib/storage/feeds.js +1 -17
  20. package/lib/storage/filters.js +26 -197
  21. package/lib/storage/items-read-state.js +30 -59
  22. package/lib/storage/items-retention.js +1 -3
  23. package/lib/storage/items.js +0 -14
  24. package/lib/utils/blogroll-notify.js +3 -3
  25. package/lib/utils/constants.js +7 -0
  26. package/lib/utils/jf2.js +0 -109
  27. package/lib/utils/pagination.js +4 -11
  28. package/lib/utils/sanitize.js +1 -2
  29. package/lib/utils/validation.js +0 -10
  30. package/lib/webmention/processor.js +2 -95
  31. package/lib/websub/handler.js +2 -2
  32. package/lib/websub/subscriber.js +0 -17
  33. package/locales/en.json +3 -27
  34. package/package.json +1 -4
  35. package/lib/search/indexer.js +0 -90
  36. package/lib/storage/items-search.js +0 -34
  37. package/lib/storage/read-state.js +0 -109
  38. package/lib/websub/discovery.js +0 -129
@@ -30,7 +30,7 @@ export async function processWebmention(application, source, target, userId) {
30
30
  const verification = await verifyWebmention(source, target);
31
31
 
32
32
  if (!verification.verified) {
33
- console.log(
33
+ console.info(
34
34
  `[Microsub] Webmention verification failed: ${verification.error}`,
35
35
  );
36
36
  return {
@@ -87,7 +87,7 @@ export async function processWebmention(application, source, target, userId) {
87
87
  });
88
88
  }
89
89
 
90
- console.log(
90
+ console.info(
91
91
  `[Microsub] Webmention processed: ${verification.type} from ${source}`,
92
92
  );
93
93
 
@@ -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
- }
@@ -64,7 +64,7 @@ export async function verify(request, response) {
64
64
  });
65
65
  }
66
66
 
67
- console.log(`[Microsub] WebSub subscription verified for ${feed.url}`);
67
+ console.info(`[Microsub] WebSub subscription verified for ${feed.url}`);
68
68
 
69
69
  // Return challenge to verify subscription
70
70
  response.type("text/plain").send(challenge);
@@ -143,7 +143,7 @@ async function processWebsubContent(application, feed, contentType, body) {
143
143
  // Parse the pushed content
144
144
  const parsed = await parseFeed(content, feed.url, { contentType });
145
145
 
146
- console.log(
146
+ console.info(
147
147
  `[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
148
148
  );
149
149
 
@@ -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/locales/en.json CHANGED
@@ -10,7 +10,6 @@
10
10
  "showRead": "Show read ({{count}})",
11
11
  "hideRead": "Hide read items",
12
12
  "allRead": "All caught up!",
13
- "newer": "Newer",
14
13
  "older": "Older"
15
14
  },
16
15
  "channels": {
@@ -18,34 +17,21 @@
18
17
  "name": "Channel name",
19
18
  "new": "New channel",
20
19
  "create": "Create channel",
21
- "delete": "Delete channel",
22
20
  "settings": "Channel settings",
23
- "empty": "No channels yet. Create one to get started.",
24
- "notifications": "Notifications"
21
+ "empty": "No channels yet. Create one to get started."
25
22
  },
26
23
  "timeline": {
27
- "title": "Timeline",
28
24
  "empty": "No items in this channel",
29
- "markRead": "Mark as read",
30
- "markUnread": "Mark as unread",
31
- "remove": "Remove"
25
+ "markRead": "Mark as read"
32
26
  },
33
27
  "feeds": {
34
28
  "title": "Feeds",
35
29
  "follow": "Follow",
36
30
  "subscribe": "Subscribe to a feed",
37
- "unfollow": "Unfollow",
38
31
  "empty": "No feeds followed in this channel",
39
32
  "url": "Feed URL",
40
33
  "urlPlaceholder": "https://example.com/feed.xml",
41
- "edit": "Edit feed",
42
- "rediscover": "Rediscover feed",
43
- "refresh": "Refresh now",
44
- "status": {
45
- "active": "Active",
46
- "error": "Error",
47
- "stale": "Stale"
48
- }
34
+ "edit": "Edit feed"
49
35
  },
50
36
  "item": {
51
37
  "reply": "Reply",
@@ -101,20 +87,10 @@
101
87
  "submit": "Search",
102
88
  "noResults": "No results found"
103
89
  },
104
- "preview": {
105
- "title": "Preview",
106
- "subscribe": "Subscribe to this feed"
107
- },
108
90
  "views": {
109
91
  "channels": "Channels",
110
92
  "deck": "Deck",
111
93
  "timeline": "Timeline"
112
- },
113
- "error": {
114
- "channelNotFound": "Channel not found",
115
- "feedNotFound": "Feed not found",
116
- "invalidUrl": "Invalid URL",
117
- "invalidAction": "Invalid action"
118
94
  }
119
95
  }
120
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.63",
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,90 +0,0 @@
1
- /**
2
- * Search indexer for MongoDB text search
3
- * @module search/indexer
4
- */
5
-
6
- /**
7
- * Create text indexes for microsub items
8
- * @param {object} application - Indiekit application
9
- * @returns {Promise<void>}
10
- */
11
- export async function createSearchIndexes(application) {
12
- const itemsCollection = application.collections.get("microsub_items");
13
-
14
- // Create compound text index for full-text search
15
- await itemsCollection.createIndex(
16
- {
17
- name: "text",
18
- "content.text": "text",
19
- "content.html": "text",
20
- summary: "text",
21
- "author.name": "text",
22
- },
23
- {
24
- name: "text_search",
25
- weights: {
26
- name: 10,
27
- "content.text": 5,
28
- summary: 3,
29
- "author.name": 2,
30
- },
31
- default_language: "english",
32
- background: true,
33
- },
34
- );
35
-
36
- // Create index for channel + published for efficient timeline queries
37
- await itemsCollection.createIndex(
38
- { channelId: 1, published: -1 },
39
- { name: "channel_timeline" },
40
- );
41
-
42
- // Create index for deduplication
43
- await itemsCollection.createIndex(
44
- { channelId: 1, uid: 1 },
45
- { name: "channel_uid", unique: true },
46
- );
47
-
48
- // Create index for feed-based queries
49
- await itemsCollection.createIndex({ feedId: 1 }, { name: "feed_items" });
50
- }
51
-
52
- /**
53
- * Rebuild search indexes (drops and recreates)
54
- * @param {object} application - Indiekit application
55
- * @returns {Promise<void>}
56
- */
57
- export async function rebuildSearchIndexes(application) {
58
- const itemsCollection = application.collections.get("microsub_items");
59
-
60
- // Drop existing text index
61
- try {
62
- await itemsCollection.dropIndex("text_search");
63
- } catch {
64
- // Index may not exist
65
- }
66
-
67
- // Recreate indexes
68
- await createSearchIndexes(application);
69
- }
70
-
71
- /**
72
- * Get search index stats
73
- * @param {object} application - Indiekit application
74
- * @returns {Promise<object>} Index statistics
75
- */
76
- export async function getSearchIndexStats(application) {
77
- const itemsCollection = application.collections.get("microsub_items");
78
-
79
- const indexes = await itemsCollection.indexes();
80
- const stats = await itemsCollection.stats();
81
-
82
- return {
83
- indexes: indexes.map((index) => ({
84
- name: index.name,
85
- key: index.key,
86
- })),
87
- totalDocuments: stats.count,
88
- size: stats.size,
89
- };
90
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Timeline item search
3
- * @module storage/items-search
4
- */
5
-
6
- import { ObjectId } from "mongodb";
7
-
8
- import { getCollection, transformToJf2 } from "./items.js";
9
-
10
- /**
11
- * Search items by text
12
- * @param {object} application - Indiekit application
13
- * @param {ObjectId|string} channelId - Channel ObjectId
14
- * @param {string} query - Search query
15
- * @param {number} [limit] - Max results
16
- * @returns {Promise<Array>} Array of matching items
17
- */
18
- export async function searchItems(application, channelId, query, limit = 20) {
19
- const collection = getCollection(application);
20
- const objectId =
21
- typeof channelId === "string" ? new ObjectId(channelId) : channelId;
22
-
23
- // Use MongoDB text index for efficient full-text search
24
- const items = await collection
25
- .find({
26
- channelId: objectId,
27
- $text: { $search: query },
28
- })
29
- .sort({ score: { $meta: "textScore" } })
30
- .limit(limit)
31
- .toArray();
32
-
33
- return items.map((item) => transformToJf2(item));
34
- }
@@ -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
- }