@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.
- package/lib/cache/redis.js +0 -31
- package/lib/controllers/block.js +0 -2
- package/lib/controllers/channels.js +0 -2
- package/lib/controllers/events.js +0 -2
- package/lib/controllers/follow.js +0 -2
- package/lib/controllers/mute.js +0 -2
- package/lib/controllers/preview.js +0 -2
- package/lib/controllers/reader/channel.js +0 -1
- package/lib/controllers/reader/index.js +2 -38
- package/lib/controllers/search.js +0 -2
- package/lib/controllers/timeline.js +0 -2
- package/lib/feeds/discovery.js +1 -1
- package/lib/feeds/normalizer.js +2 -2
- package/lib/media/proxy.js +5 -5
- package/lib/polling/scheduler.js +4 -14
- package/lib/polling/tier.js +6 -51
- package/lib/realtime/broker.js +3 -75
- package/lib/storage/channels.js +1 -1
- package/lib/storage/feeds.js +1 -17
- package/lib/storage/filters.js +26 -197
- package/lib/storage/items-read-state.js +30 -59
- package/lib/storage/items-retention.js +1 -3
- package/lib/storage/items.js +0 -14
- package/lib/utils/blogroll-notify.js +3 -3
- package/lib/utils/constants.js +7 -0
- package/lib/utils/jf2.js +0 -109
- package/lib/utils/pagination.js +4 -11
- package/lib/utils/sanitize.js +1 -2
- package/lib/utils/validation.js +0 -10
- package/lib/webmention/processor.js +2 -95
- package/lib/websub/handler.js +2 -2
- package/lib/websub/subscriber.js +0 -17
- package/locales/en.json +3 -27
- package/package.json +1 -4
- package/lib/search/indexer.js +0 -90
- package/lib/storage/items-search.js +0 -34
- package/lib/storage/read-state.js +0 -109
- 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.
|
|
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.
|
|
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
|
-
}
|
package/lib/websub/handler.js
CHANGED
|
@@ -64,7 +64,7 @@ export async function verify(request, response) {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
console.
|
|
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.
|
|
146
|
+
console.info(
|
|
147
147
|
`[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
|
|
148
148
|
);
|
|
149
149
|
|
package/lib/websub/subscriber.js
CHANGED
|
@@ -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.
|
|
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",
|
package/lib/search/indexer.js
DELETED
|
@@ -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
|
-
}
|
package/lib/websub/discovery.js
DELETED
|
@@ -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
|
-
}
|