@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.
- 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/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 +0 -10
- 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.js +0 -14
- package/lib/utils/pagination.js +4 -11
- package/lib/utils/validation.js +0 -10
- package/lib/webmention/processor.js +0 -93
- package/lib/websub/subscriber.js +0 -17
- package/package.json +1 -4
- package/lib/storage/read-state.js +0 -109
- package/lib/websub/discovery.js +0 -129
package/lib/cache/redis.js
CHANGED
|
@@ -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
|
-
}
|
package/lib/controllers/block.js
CHANGED
package/lib/controllers/mute.js
CHANGED
|
@@ -1,45 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reader controller
|
|
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,
|
package/lib/feeds/discovery.js
CHANGED
|
@@ -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
|
-
|
|
81
|
+
function filterMainFeeds(feeds) {
|
|
82
82
|
return feeds.filter((feed) => feed.valid && !feed.isCommentsFeed);
|
|
83
83
|
}
|
|
84
84
|
|
package/lib/feeds/normalizer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
+
function extractUrl(value) {
|
|
112
112
|
if (!value) {
|
|
113
113
|
return;
|
|
114
114
|
}
|
package/lib/media/proxy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/lib/polling/scheduler.js
CHANGED
|
@@ -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
|
-
}
|
package/lib/polling/tier.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/realtime/broker.js
CHANGED
|
@@ -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
|
|
117
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|
package/lib/storage/channels.js
CHANGED
|
@@ -29,7 +29,7 @@ const CHANNEL_COLORS = [
|
|
|
29
29
|
* @param {number} order - Channel order index
|
|
30
30
|
* @returns {string} Hex color
|
|
31
31
|
*/
|
|
32
|
-
|
|
32
|
+
function getChannelColor(order) {
|
|
33
33
|
return CHANNEL_COLORS[Math.abs(order) % CHANNEL_COLORS.length];
|
|
34
34
|
}
|
|
35
35
|
|
package/lib/storage/feeds.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
package/lib/storage/filters.js
CHANGED
|
@@ -1,117 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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}
|
|
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}
|
|
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
|
|
54
|
+
// Invalid regex — skip the filter rather than rejecting every item.
|
|
150
55
|
return true;
|
|
151
56
|
}
|
|
152
57
|
}
|
|
153
58
|
|
|
154
59
|
/**
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|
package/lib/storage/items.js
CHANGED
|
@@ -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
|
package/lib/utils/pagination.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/utils/validation.js
CHANGED
|
@@ -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
|
-
}
|
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/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",
|
|
@@ -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
|
-
}
|