@rmdes/indiekit-endpoint-microsub 1.0.55 → 1.0.57
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/assets/reader.js +408 -0
- package/index.js +61 -49
- package/lib/activitypub/outbox-fetcher.js +14 -2
- package/lib/cache/redis.js +26 -7
- package/lib/controllers/channels.js +2 -2
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/search.js +6 -0
- package/lib/controllers/timeline.js +6 -4
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/capabilities.js +5 -0
- package/lib/feeds/fetcher.js +5 -28
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +22 -614
- package/lib/feeds/rss.js +1 -1
- package/lib/media/proxy.js +82 -27
- package/lib/polling/processor.js +30 -21
- package/lib/polling/scheduler.js +2 -0
- package/lib/realtime/broker.js +6 -1
- package/lib/storage/channels.js +53 -42
- package/lib/storage/feeds.js +3 -1
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +113 -610
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/constants.js +7 -0
- package/lib/utils/csrf.js +51 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/sanitize.js +61 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/utils/validation.js +8 -2
- package/lib/webmention/processor.js +1 -1
- package/lib/webmention/verifier.js +10 -21
- package/lib/websub/subscriber.js +12 -0
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +3 -1
- package/views/actor.njk +2 -0
- package/views/channel-new.njk +1 -0
- package/views/channel.njk +3 -344
- package/views/compose.njk +1 -0
- package/views/deck-settings.njk +1 -0
- package/views/feed-edit.njk +3 -0
- package/views/feeds.njk +4 -0
- package/views/layouts/reader.njk +1 -0
- package/views/search.njk +2 -0
- package/views/settings.njk +2 -0
- package/views/timeline.njk +3 -271
- package/lib/controllers/reader.js +0 -1580
package/lib/media/proxy.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
|
+
import dns from "node:dns/promises";
|
|
7
8
|
|
|
8
9
|
import { getCache, setCache } from "../cache/redis.js";
|
|
9
10
|
|
|
@@ -20,39 +21,59 @@ const BLOCKED_IP_PREFIXES = [
|
|
|
20
21
|
];
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* Check if
|
|
24
|
+
* Check if an IP address is in a private/internal range
|
|
25
|
+
* @param {string} ip - IP address to check
|
|
26
|
+
* @returns {boolean} True if private
|
|
27
|
+
*/
|
|
28
|
+
function isPrivateIp(ip) {
|
|
29
|
+
if (ip === "::1" || ip === "127.0.0.1") return true;
|
|
30
|
+
|
|
31
|
+
for (const prefix of BLOCKED_IP_PREFIXES) {
|
|
32
|
+
if (ip.startsWith(prefix)) return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 172.16.0.0/12
|
|
36
|
+
const match172 = ip.match(/^172\.(\d+)\./);
|
|
37
|
+
if (match172) {
|
|
38
|
+
const second = Number.parseInt(match172[1], 10);
|
|
39
|
+
if (second >= 16 && second <= 31) return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a URL targets a private/internal address.
|
|
47
|
+
* Performs both string-based hostname checks AND DNS resolution
|
|
48
|
+
* to prevent DNS rebinding attacks.
|
|
49
|
+
*
|
|
24
50
|
* @param {string} urlString - URL to check
|
|
25
|
-
* @returns {boolean} True if the URL targets a private/internal address
|
|
51
|
+
* @returns {Promise<boolean>} True if the URL targets a private/internal address
|
|
26
52
|
*/
|
|
27
|
-
export function isPrivateUrl(urlString) {
|
|
53
|
+
export async function isPrivateUrl(urlString) {
|
|
28
54
|
try {
|
|
29
55
|
const parsed = new URL(urlString);
|
|
30
56
|
const hostname = parsed.hostname;
|
|
31
57
|
|
|
58
|
+
// Block non-HTTP protocols
|
|
59
|
+
if (!["http:", "https:"].includes(parsed.protocol)) return true;
|
|
60
|
+
|
|
32
61
|
// Block known private hostnames
|
|
33
|
-
if (BLOCKED_HOSTNAMES.has(hostname))
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
62
|
+
if (BLOCKED_HOSTNAMES.has(hostname)) return true;
|
|
36
63
|
|
|
37
64
|
// Block IPv6 loopback
|
|
38
|
-
if (hostname === "::1" || hostname === "[::1]")
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
65
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
41
66
|
|
|
42
|
-
// Block private IPv4 ranges
|
|
43
|
-
|
|
44
|
-
if (hostname.startsWith(prefix)) {
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
67
|
+
// Block private IPv4 ranges (string check for literal IPs)
|
|
68
|
+
if (isPrivateIp(hostname)) return true;
|
|
48
69
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
// DNS resolution check — catches domains resolving to private IPs
|
|
71
|
+
try {
|
|
72
|
+
const { address } = await dns.lookup(hostname);
|
|
73
|
+
if (isPrivateIp(address)) return true;
|
|
74
|
+
} catch {
|
|
75
|
+
// DNS resolution failure — block as precaution
|
|
76
|
+
return true;
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
return false;
|
|
@@ -68,8 +89,8 @@ const ALLOWED_TYPES = new Set([
|
|
|
68
89
|
"image/png",
|
|
69
90
|
"image/gif",
|
|
70
91
|
"image/webp",
|
|
71
|
-
"image/svg+xml",
|
|
72
92
|
"image/avif",
|
|
93
|
+
// image/svg+xml intentionally excluded — SVGs can contain embedded JavaScript
|
|
73
94
|
]);
|
|
74
95
|
|
|
75
96
|
/**
|
|
@@ -81,6 +102,34 @@ export function hashUrl(url) {
|
|
|
81
102
|
return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Generate HMAC signature for a media proxy URL
|
|
107
|
+
* @param {string} url - Original image URL
|
|
108
|
+
* @returns {string} HMAC hex signature (16 chars)
|
|
109
|
+
*/
|
|
110
|
+
export function signProxyUrl(url) {
|
|
111
|
+
const secret = process.env.SECRET || "microsub-default-secret";
|
|
112
|
+
return crypto
|
|
113
|
+
.createHmac("sha256", secret)
|
|
114
|
+
.update(url)
|
|
115
|
+
.digest("hex")
|
|
116
|
+
.slice(0, 16);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify HMAC signature for a media proxy URL
|
|
121
|
+
* @param {string} url - Original image URL
|
|
122
|
+
* @param {string} sig - Submitted signature
|
|
123
|
+
* @returns {boolean} Whether signature is valid
|
|
124
|
+
*/
|
|
125
|
+
export function verifyProxySignature(url, sig) {
|
|
126
|
+
if (!sig) return false;
|
|
127
|
+
const expected = signProxyUrl(url);
|
|
128
|
+
// Constant-time comparison
|
|
129
|
+
if (sig.length !== expected.length) return false;
|
|
130
|
+
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
|
131
|
+
}
|
|
132
|
+
|
|
84
133
|
/**
|
|
85
134
|
* Get the proxied URL for an image
|
|
86
135
|
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
|
@@ -103,7 +152,8 @@ export function getProxiedUrl(baseUrl, originalUrl) {
|
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
const hash = hashUrl(originalUrl);
|
|
106
|
-
|
|
155
|
+
const sig = signProxyUrl(originalUrl);
|
|
156
|
+
return `${baseUrl}/microsub/media/${hash}?url=${encodeURIComponent(originalUrl)}&sig=${sig}`;
|
|
107
157
|
}
|
|
108
158
|
|
|
109
159
|
/**
|
|
@@ -155,7 +205,7 @@ export function proxyItemImages(item, baseUrl) {
|
|
|
155
205
|
*/
|
|
156
206
|
export async function fetchImage(redis, url) {
|
|
157
207
|
// Block private/internal URLs (defense-in-depth)
|
|
158
|
-
if (isPrivateUrl(url)) {
|
|
208
|
+
if (await isPrivateUrl(url)) {
|
|
159
209
|
console.error(`[Microsub] Media proxy blocked private URL: ${url}`);
|
|
160
210
|
return;
|
|
161
211
|
}
|
|
@@ -239,12 +289,17 @@ export async function fetchImage(redis, url) {
|
|
|
239
289
|
* @returns {Promise<void>}
|
|
240
290
|
*/
|
|
241
291
|
export async function handleMediaProxy(request, response) {
|
|
242
|
-
const { url } = request.query;
|
|
292
|
+
const { url, sig } = request.query;
|
|
243
293
|
|
|
244
294
|
if (!url) {
|
|
245
295
|
return response.status(400).send("Missing url parameter");
|
|
246
296
|
}
|
|
247
297
|
|
|
298
|
+
// Verify HMAC signature (prevents abuse as open proxy)
|
|
299
|
+
if (!verifyProxySignature(url, sig)) {
|
|
300
|
+
return response.status(403).send("Invalid proxy signature");
|
|
301
|
+
}
|
|
302
|
+
|
|
248
303
|
// Validate URL
|
|
249
304
|
try {
|
|
250
305
|
const parsed = new URL(url);
|
|
@@ -256,7 +311,7 @@ export async function handleMediaProxy(request, response) {
|
|
|
256
311
|
}
|
|
257
312
|
|
|
258
313
|
// Block requests to private/internal networks (SSRF protection)
|
|
259
|
-
if (isPrivateUrl(url)) {
|
|
314
|
+
if (await isPrivateUrl(url)) {
|
|
260
315
|
return response.status(403).send("URL not allowed");
|
|
261
316
|
}
|
|
262
317
|
|
package/lib/polling/processor.js
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* @module polling/processor
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const FEED_PROCESS_TIMEOUT = 60_000; // 60 seconds max per feed
|
|
7
|
+
const MAX_ITEMS_PER_CYCLE = 100; // Max items to process per feed per cycle
|
|
8
|
+
|
|
6
9
|
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
10
|
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
8
11
|
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
9
|
-
import {
|
|
12
|
+
import { getChannelById } from "../storage/channels.js";
|
|
10
13
|
import {
|
|
11
14
|
updateFeed,
|
|
12
15
|
updateFeedAfterFetch,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
} from "../storage/feeds.js";
|
|
16
19
|
import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
|
|
17
20
|
import { addItem } from "../storage/items.js";
|
|
21
|
+
import { classifyUrl } from "../utils/source-type.js";
|
|
18
22
|
import {
|
|
19
23
|
subscribe as websubSubscribe,
|
|
20
24
|
getCallbackUrl,
|
|
@@ -40,7 +44,7 @@ export async function processFeed(application, feed) {
|
|
|
40
44
|
|
|
41
45
|
try {
|
|
42
46
|
// Get Redis client for caching
|
|
43
|
-
const redis = getRedisClient(application);
|
|
47
|
+
const redis = await getRedisClient(application);
|
|
44
48
|
|
|
45
49
|
// Fetch and parse the feed
|
|
46
50
|
const parsed = await fetchAndParseFeed(feed.url, {
|
|
@@ -69,11 +73,14 @@ export async function processFeed(application, feed) {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
// Get channel for filtering
|
|
72
|
-
const channel = await
|
|
76
|
+
const channel = await getChannelById(application, feed.channelId);
|
|
73
77
|
|
|
74
|
-
// Process items
|
|
78
|
+
// Process items (limited to MAX_ITEMS_PER_CYCLE per feed per cycle)
|
|
75
79
|
let newItemCount = 0;
|
|
80
|
+
let processedCount = 0;
|
|
76
81
|
for (const item of parsed.items) {
|
|
82
|
+
if (processedCount >= MAX_ITEMS_PER_CYCLE) break;
|
|
83
|
+
processedCount++;
|
|
77
84
|
// Apply channel filters
|
|
78
85
|
if (channel?.settings && !passesFilters(item, channel.settings)) {
|
|
79
86
|
continue;
|
|
@@ -90,7 +97,7 @@ export async function processFeed(application, feed) {
|
|
|
90
97
|
if (feed.capabilities?.source_type) {
|
|
91
98
|
item._source.source_type = feed.capabilities.source_type;
|
|
92
99
|
} else {
|
|
93
|
-
item._source.source_type =
|
|
100
|
+
item._source.source_type = classifyUrl(feed.url).type;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
// Store the item
|
|
@@ -236,21 +243,6 @@ export async function processFeed(application, feed) {
|
|
|
236
243
|
return result;
|
|
237
244
|
}
|
|
238
245
|
|
|
239
|
-
/**
|
|
240
|
-
* Infer source type from feed URL when capabilities haven't been detected yet
|
|
241
|
-
* @param {string} url - Feed URL
|
|
242
|
-
* @returns {string} Source type
|
|
243
|
-
*/
|
|
244
|
-
function inferSourceType(url) {
|
|
245
|
-
if (!url) return "web";
|
|
246
|
-
const lower = url.toLowerCase();
|
|
247
|
-
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "bluesky";
|
|
248
|
-
if (lower.includes("mastodon.") || lower.includes("mstdn.") ||
|
|
249
|
-
lower.includes("fosstodon.") || lower.includes("pleroma.") ||
|
|
250
|
-
lower.includes("misskey.") || lower.includes("pixelfed.")) return "mastodon";
|
|
251
|
-
return "web";
|
|
252
|
-
}
|
|
253
|
-
|
|
254
246
|
/**
|
|
255
247
|
* Check if an item passes channel filters
|
|
256
248
|
* @param {object} item - Feed item
|
|
@@ -276,7 +268,24 @@ export async function processFeedBatch(application, feeds, options = {}) {
|
|
|
276
268
|
for (let index = 0; index < feeds.length; index += concurrency) {
|
|
277
269
|
const batch = feeds.slice(index, index + concurrency);
|
|
278
270
|
const batchResults = await Promise.all(
|
|
279
|
-
batch.map((feed) =>
|
|
271
|
+
batch.map((feed) =>
|
|
272
|
+
Promise.race([
|
|
273
|
+
processFeed(application, feed),
|
|
274
|
+
new Promise((resolve) =>
|
|
275
|
+
setTimeout(
|
|
276
|
+
() =>
|
|
277
|
+
resolve({
|
|
278
|
+
feedId: feed._id,
|
|
279
|
+
url: feed.url,
|
|
280
|
+
success: false,
|
|
281
|
+
itemsAdded: 0,
|
|
282
|
+
error: "Feed processing timeout",
|
|
283
|
+
}),
|
|
284
|
+
FEED_PROCESS_TIMEOUT,
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
]),
|
|
288
|
+
),
|
|
280
289
|
);
|
|
281
290
|
results.push(...batchResults);
|
|
282
291
|
}
|
package/lib/polling/scheduler.js
CHANGED
|
@@ -7,6 +7,8 @@ import { getFeedsToFetch } from "../storage/feeds.js";
|
|
|
7
7
|
|
|
8
8
|
import { processFeedBatch } from "./processor.js";
|
|
9
9
|
|
|
10
|
+
// TODO: Refactor scheduler to a class that accepts `application` as constructor
|
|
11
|
+
// argument. Module-level singletons prevent unit testing and multiple instances.
|
|
10
12
|
let schedulerInterval;
|
|
11
13
|
let indiekitInstance;
|
|
12
14
|
let isRunning = false;
|
package/lib/realtime/broker.js
CHANGED
|
@@ -62,7 +62,12 @@ export function removeClient(response) {
|
|
|
62
62
|
(c) => c.userId === client.userId,
|
|
63
63
|
);
|
|
64
64
|
if (!hasOtherClients) {
|
|
65
|
-
//
|
|
65
|
+
// Clean up Redis subscriber connection for this user
|
|
66
|
+
const subscriber = userSubscribers.get(client.userId);
|
|
67
|
+
if (subscriber) {
|
|
68
|
+
subscriber.quit().catch(() => {});
|
|
69
|
+
userSubscribers.delete(client.userId);
|
|
70
|
+
}
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
}
|
package/lib/storage/channels.js
CHANGED
|
@@ -112,8 +112,35 @@ export async function createChannel(application, { name, userId }) {
|
|
|
112
112
|
return channel;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
import { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get unread counts for multiple channels in a single aggregation query.
|
|
119
|
+
* Replaces the N+1 countDocuments pattern.
|
|
120
|
+
* @param {object} itemsCollection - MongoDB items collection
|
|
121
|
+
* @param {Array<import("mongodb").ObjectId>} channelIds - Channel IDs
|
|
122
|
+
* @param {string} userId - User ID
|
|
123
|
+
* @returns {Promise<Map<string, number>>} Map of channelId string → unread count
|
|
124
|
+
*/
|
|
125
|
+
async function getUnreadCounts(itemsCollection, channelIds, userId) {
|
|
126
|
+
const cutoffDate = new Date();
|
|
127
|
+
cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
|
|
128
|
+
|
|
129
|
+
const pipeline = [
|
|
130
|
+
{
|
|
131
|
+
$match: {
|
|
132
|
+
channelId: { $in: channelIds },
|
|
133
|
+
readBy: { $ne: userId },
|
|
134
|
+
published: { $gte: cutoffDate },
|
|
135
|
+
_stripped: { $ne: true },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{ $group: { _id: "$channelId", count: { $sum: 1 } } },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const results = await itemsCollection.aggregate(pipeline).toArray();
|
|
142
|
+
return new Map(results.map((r) => [r._id.toString(), r.count]));
|
|
143
|
+
}
|
|
117
144
|
|
|
118
145
|
/**
|
|
119
146
|
* Get all channels for a user
|
|
@@ -133,27 +160,18 @@ export async function getChannels(application, userId) {
|
|
|
133
160
|
.sort({ order: 1 })
|
|
134
161
|
.toArray();
|
|
135
162
|
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
uid: channel.uid,
|
|
152
|
-
name: channel.name,
|
|
153
|
-
unread: unreadCount > 0 ? unreadCount : false,
|
|
154
|
-
};
|
|
155
|
-
}),
|
|
156
|
-
);
|
|
163
|
+
// Single aggregation query for all channel unread counts
|
|
164
|
+
const channelIds = channels.map((c) => c._id);
|
|
165
|
+
const unreadMap = await getUnreadCounts(itemsCollection, channelIds, userId);
|
|
166
|
+
|
|
167
|
+
const channelsWithCounts = channels.map((channel) => {
|
|
168
|
+
const unreadCount = unreadMap.get(channel._id.toString()) || 0;
|
|
169
|
+
return {
|
|
170
|
+
uid: channel.uid,
|
|
171
|
+
name: channel.name,
|
|
172
|
+
unread: unreadCount > 0 ? unreadCount : false,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
157
175
|
|
|
158
176
|
// Always include notifications channel first
|
|
159
177
|
const notificationsChannel = channelsWithCounts.find(
|
|
@@ -189,25 +207,18 @@ export async function getChannelsWithColors(application, userId) {
|
|
|
189
207
|
.sort({ order: 1 })
|
|
190
208
|
.toArray();
|
|
191
209
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
...channel,
|
|
206
|
-
color: channel.color || getChannelColor(index),
|
|
207
|
-
unread: unreadCount > 0 ? unreadCount : false,
|
|
208
|
-
};
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
210
|
+
// Single aggregation query for all channel unread counts
|
|
211
|
+
const channelIds = channels.map((c) => c._id);
|
|
212
|
+
const unreadMap = await getUnreadCounts(itemsCollection, channelIds, userId);
|
|
213
|
+
|
|
214
|
+
const enriched = channels.map((channel, index) => {
|
|
215
|
+
const unreadCount = unreadMap.get(channel._id.toString()) || 0;
|
|
216
|
+
return {
|
|
217
|
+
...channel,
|
|
218
|
+
color: channel.color || getChannelColor(index),
|
|
219
|
+
unread: unreadCount > 0 ? unreadCount : false,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
211
222
|
|
|
212
223
|
// Notifications first, then by order
|
|
213
224
|
const notifications = enriched.find((c) => c.uid === "notifications");
|
package/lib/storage/feeds.js
CHANGED
|
@@ -249,7 +249,7 @@ export async function deleteFeedsForChannel(application, channelId) {
|
|
|
249
249
|
* @param {object} application - Indiekit application
|
|
250
250
|
* @returns {Promise<Array>} Array of feeds to fetch
|
|
251
251
|
*/
|
|
252
|
-
export async function getFeedsToFetch(application) {
|
|
252
|
+
export async function getFeedsToFetch(application, limit = 25) {
|
|
253
253
|
const collection = getCollection(application);
|
|
254
254
|
const now = new Date();
|
|
255
255
|
|
|
@@ -257,6 +257,8 @@ export async function getFeedsToFetch(application) {
|
|
|
257
257
|
.find({
|
|
258
258
|
$or: [{ nextFetchAt: undefined }, { nextFetchAt: { $lte: now } }],
|
|
259
259
|
})
|
|
260
|
+
.sort({ nextFetchAt: 1 })
|
|
261
|
+
.limit(limit)
|
|
260
262
|
.toArray();
|
|
261
263
|
}
|
|
262
264
|
|