@rmdes/indiekit-endpoint-microsub 1.0.55 → 1.0.56

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/index.js CHANGED
@@ -2,11 +2,13 @@ import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
4
  import express from "express";
5
+ import rateLimit from "express-rate-limit";
5
6
 
6
7
  import { microsubController } from "./lib/controllers/microsub.js";
7
8
  import { opmlController } from "./lib/controllers/opml.js";
8
9
  import { readerController } from "./lib/controllers/reader.js";
9
10
  import { handleMediaProxy } from "./lib/media/proxy.js";
11
+ import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
10
12
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
11
13
  import { ensureActivityPubChannel } from "./lib/storage/channels.js";
12
14
  import {
@@ -77,17 +79,14 @@ export default class MicrosubEndpoint {
77
79
  router.get("/", microsubController.get);
78
80
  router.post("/", microsubController.post);
79
81
 
80
- // WebSub callback endpoint
81
- router.get("/websub/:id", websubHandler.verify);
82
- router.post("/websub/:id", websubHandler.receive);
83
-
84
- // Webmention receiving endpoint
85
- router.post("/webmention", webmentionReceiver.receive);
86
-
87
- // Media proxy endpoint
88
- router.get("/media/:hash", handleMediaProxy);
82
+ // WebSub, webmention, and media proxy are registered in routesPublic only
83
+ // (they must be accessible without authentication)
89
84
 
90
85
  // Reader UI routes (mounted as sub-router for correct baseUrl)
86
+ // CSRF protection: generate token on all requests, validate on POST
87
+ readerRouter.use(csrfToken);
88
+ readerRouter.use(csrfValidate);
89
+
91
90
  readerRouter.get("/", readerController.index);
92
91
  readerRouter.get("/channels", readerController.channels);
93
92
  readerRouter.get("/channels/new", readerController.newChannel);
@@ -155,15 +154,20 @@ export default class MicrosubEndpoint {
155
154
  get routesPublic() {
156
155
  const publicRouter = express.Router();
157
156
 
157
+ // Rate limiters for public endpoints
158
+ const mediaLimiter = rateLimit({ windowMs: 60_000, max: 120, message: "Too many requests" });
159
+ const websubLimiter = rateLimit({ windowMs: 60_000, max: 30, message: "Too many requests" });
160
+ const webmentionLimiter = rateLimit({ windowMs: 60_000, max: 10, message: "Too many requests" });
161
+
158
162
  // WebSub verification must be public for hubs to verify
159
- publicRouter.get("/websub/:id", websubHandler.verify);
160
- publicRouter.post("/websub/:id", websubHandler.receive);
163
+ publicRouter.get("/websub/:id", websubLimiter, websubHandler.verify);
164
+ publicRouter.post("/websub/:id", websubLimiter, websubHandler.receive);
161
165
 
162
166
  // Webmention endpoint must be public
163
- publicRouter.post("/webmention", webmentionReceiver.receive);
167
+ publicRouter.post("/webmention", webmentionLimiter, webmentionReceiver.receive);
164
168
 
165
169
  // Media proxy must be public for images to load
166
- publicRouter.get("/media/:hash", handleMediaProxy);
170
+ publicRouter.get("/media/:hash", mediaLimiter, handleMediaProxy);
167
171
 
168
172
  return publicRouter;
169
173
  }
@@ -222,6 +226,13 @@ export default class MicrosubEndpoint {
222
226
  cleanupStaleItems(indiekit).catch((error) => {
223
227
  console.warn("[Microsub] Stale cleanup failed:", error.message);
224
228
  });
229
+
230
+ // Schedule daily stale cleanup (items accumulate between restarts)
231
+ setInterval(() => {
232
+ cleanupStaleItems(indiekit).catch((error) => {
233
+ console.warn("[Microsub] Scheduled stale cleanup failed:", error.message);
234
+ });
235
+ }, 24 * 60 * 60 * 1000);
225
236
  } else {
226
237
  console.warn(
227
238
  "[Microsub] Database not available at init, scheduler not started",
@@ -5,6 +5,11 @@
5
5
  * @module activitypub/outbox-fetcher
6
6
  */
7
7
 
8
+ import sanitizeHtml from "sanitize-html";
9
+
10
+ import { isPrivateUrl } from "../media/proxy.js";
11
+ import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
12
+
8
13
  const AP_ACCEPT =
9
14
  'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
10
15
  const FETCH_TIMEOUT = 10_000;
@@ -121,8 +126,9 @@ function activityToJf2(activity, actorInfo) {
121
126
  ]);
122
127
  if (!contentTypes.has(object.type)) return null;
123
128
 
124
- const contentHtml = object.content || "";
125
- const contentText = stripHtml(contentHtml);
129
+ const rawHtml = object.content || "";
130
+ const contentHtml = rawHtml ? sanitizeHtml(rawHtml, SANITIZE_OPTIONS) : "";
131
+ const contentText = stripHtml(rawHtml);
126
132
 
127
133
  const jf2 = {
128
134
  type: "entry",
@@ -212,6 +218,12 @@ function extractMedia(attachments, mediaPrefix) {
212
218
  async function fetchJson(url) {
213
219
  if (!url) return null;
214
220
 
221
+ // SSRF protection — block private/internal IPs (including DNS rebinding)
222
+ if (await isPrivateUrl(url)) {
223
+ console.warn(`[Microsub] AP fetch blocked private URL: ${url}`);
224
+ return null;
225
+ }
226
+
215
227
  const controller = new AbortController();
216
228
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
217
229
 
@@ -56,6 +56,15 @@ export function getRedisClient(application) {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Namespace cache keys to prevent cross-instance collisions
61
+ * @param {string} key - Raw cache key
62
+ * @returns {string} Namespaced key
63
+ */
64
+ function nsKey(key) {
65
+ return `microsub:${key}`;
66
+ }
67
+
59
68
  /**
60
69
  * Get value from cache
61
70
  * @param {object} redis - Redis client
@@ -68,7 +77,7 @@ export async function getCache(redis, key) {
68
77
  }
69
78
 
70
79
  try {
71
- const value = await redis.get(key);
80
+ const value = await redis.get(nsKey(key));
72
81
  if (value) {
73
82
  return JSON.parse(value);
74
83
  }
@@ -92,9 +101,10 @@ export async function setCache(redis, key, value, ttl = 300) {
92
101
 
93
102
  try {
94
103
  const serialized = JSON.stringify(value);
104
+ const nk = nsKey(key);
95
105
  await (ttl
96
- ? redis.set(key, serialized, "EX", ttl)
97
- : redis.set(key, serialized));
106
+ ? redis.set(nk, serialized, "EX", ttl)
107
+ : redis.set(nk, serialized));
98
108
  } catch {
99
109
  // Ignore cache errors
100
110
  }
@@ -112,7 +122,7 @@ export async function deleteCache(redis, key) {
112
122
  }
113
123
 
114
124
  try {
115
- await redis.del(key);
125
+ await redis.del(nsKey(key));
116
126
  } catch {
117
127
  // Ignore cache errors
118
128
  }
@@ -17,7 +17,7 @@ import { getUserId } from "../utils/auth.js";
17
17
  import {
18
18
  validateChannel,
19
19
  validateChannelName,
20
- parseArrayParameter as parseArrayParametereter,
20
+ parseArrayParameter,
21
21
  } from "../utils/validation.js";
22
22
 
23
23
  /**
@@ -62,7 +62,7 @@ export async function action(request, response) {
62
62
 
63
63
  // Reorder channels
64
64
  if (method === "order") {
65
- const channelUids = parseArrayParametereter(request.body, "channels");
65
+ const channelUids = parseArrayParameter(request.body, "channels");
66
66
  if (channelUids.length === 0) {
67
67
  throw new IndiekitError("Missing channels[] parameter", {
68
68
  status: 400,
@@ -11,6 +11,7 @@ import {
11
11
  getChannels,
12
12
  getChannelsWithColors,
13
13
  getChannel,
14
+ getChannelById,
14
15
  createChannel,
15
16
  updateChannelSettings,
16
17
  deleteChannel,
@@ -31,6 +32,8 @@ import {
31
32
  countReadItems,
32
33
  } from "../storage/items.js";
33
34
  import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
35
+
36
+ const ACTOR_OUTBOX_LIMIT = 30;
34
37
  import { getUserId } from "../utils/auth.js";
35
38
  import {
36
39
  validateChannelName,
@@ -399,8 +402,7 @@ export async function item(request, response) {
399
402
  // Get the channel for this item (needed for mark-read)
400
403
  let channel = null;
401
404
  if (itemDocument.channelId) {
402
- const channelsCollection = application.collections.get("microsub_channels");
403
- channel = await channelsCollection.findOne({ _id: itemDocument.channelId });
405
+ channel = await getChannelById(application, itemDocument.channelId);
404
406
  }
405
407
 
406
408
  const itemBreadcrumbs = [
@@ -556,20 +558,6 @@ export async function submitCompose(request, response) {
556
558
  const bookmarkOf = request.body["bookmark-of"];
557
559
  const syndicateTo = request.body["mp-syndicate-to"];
558
560
 
559
- // Debug logging
560
- console.info(
561
- "[Microsub] submitCompose request.body:",
562
- JSON.stringify(request.body),
563
- );
564
- console.info("[Microsub] Extracted values:", {
565
- content,
566
- inReplyTo,
567
- likeOf,
568
- repostOf,
569
- bookmarkOf,
570
- syndicateTo,
571
- });
572
-
573
561
  // Get Micropub endpoint
574
562
  const micropubEndpoint = application.micropubEndpoint;
575
563
  if (!micropubEndpoint) {
@@ -629,12 +617,6 @@ export async function submitCompose(request, response) {
629
617
  }
630
618
  }
631
619
 
632
- // Debug: log what we're sending
633
- console.info("[Microsub] Sending to Micropub:", {
634
- url: micropubUrl,
635
- body: micropubData.toString(),
636
- });
637
-
638
620
  try {
639
621
  const micropubResponse = await fetch(micropubUrl, {
640
622
  method: "POST",
@@ -1268,7 +1250,7 @@ export async function actorProfile(request, response) {
1268
1250
  const canFollow = !!apPlugin;
1269
1251
 
1270
1252
  try {
1271
- const { actor, items } = await fetchActorOutbox(actorUrl, { limit: 30 });
1253
+ const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
1272
1254
 
1273
1255
  response.render("actor", {
1274
1256
  title: actor.name || "Actor",
@@ -6,6 +6,7 @@
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
8
  import { discoverFeeds } from "../feeds/hfeed.js";
9
+ import { isPrivateUrl } from "../media/proxy.js";
9
10
  import { searchWithFallback } from "../search/query.js";
10
11
  import { getChannel } from "../storage/channels.js";
11
12
  import { getUserId } from "../utils/auth.js";
@@ -35,6 +36,11 @@ export async function discover(request, response) {
35
36
  return response.json({ results: [] });
36
37
  }
37
38
 
39
+ // SSRF protection
40
+ if (await isPrivateUrl(url.href)) {
41
+ throw new IndiekitError("URL blocked (private/internal address)", { status: 400 });
42
+ }
43
+
38
44
  try {
39
45
  // Fetch the URL content
40
46
  const fetchResponse = await fetch(url.href, {
@@ -18,7 +18,7 @@ import { getUserId } from "../utils/auth.js";
18
18
  import {
19
19
  validateChannel,
20
20
  validateEntries,
21
- parseArrayParameter as parseArrayParametereter,
21
+ parseArrayParameter,
22
22
  } from "../utils/validation.js";
23
23
 
24
24
  /**
@@ -90,7 +90,7 @@ export async function action(request, response) {
90
90
  }
91
91
 
92
92
  // Get entry IDs from request
93
- const entries = parseArrayParametereter(request.body, "entry");
93
+ const entries = parseArrayParameter(request.body, "entry");
94
94
 
95
95
  switch (method) {
96
96
  case "mark_read": {
@@ -4,6 +4,8 @@
4
4
  * @module feeds/capabilities
5
5
  */
6
6
 
7
+ import { isPrivateUrl } from "../media/proxy.js";
8
+
7
9
  /**
8
10
  * Known Fediverse domain patterns
9
11
  */
@@ -136,6 +138,9 @@ async function discoverEndpoints(url) {
136
138
  micropub: null,
137
139
  };
138
140
 
141
+ // SSRF protection
142
+ if (await isPrivateUrl(url)) return endpoints;
143
+
139
144
  const controller = new AbortController();
140
145
  const timeout = setTimeout(() => controller.abort(), 15_000);
141
146
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { getCache, setCache } from "../cache/redis.js";
7
+ import { isPrivateUrl } from "../media/proxy.js";
7
8
 
8
9
  const DEFAULT_TIMEOUT = 30_000; // 30 seconds
9
10
  const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
@@ -21,6 +22,11 @@ const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
21
22
  export async function fetchFeed(url, options = {}) {
22
23
  const { etag, lastModified, timeout = DEFAULT_TIMEOUT, redis } = options;
23
24
 
25
+ // SSRF protection — block private/internal IPs (including DNS rebinding)
26
+ if (await isPrivateUrl(url)) {
27
+ throw new Error(`Feed URL blocked (private/internal address): ${url}`);
28
+ }
29
+
24
30
  // Check cache first
25
31
  if (redis) {
26
32
  const cached = await getCache(redis, `feed:${url}`);
@@ -7,6 +7,8 @@ import crypto from "node:crypto";
7
7
 
8
8
  import sanitizeHtml from "sanitize-html";
9
9
 
10
+ import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
11
+
10
12
  /**
11
13
  * Extract image URLs from HTML content.
12
14
  * Used as a fallback when no explicit photo/enclosure is provided.
@@ -89,61 +91,7 @@ function toISOStringSafe(dateInput) {
89
91
  return date ? date.toISOString() : undefined;
90
92
  }
91
93
 
92
- /**
93
- * Sanitize HTML options
94
- */
95
- const SANITIZE_OPTIONS = {
96
- allowedTags: [
97
- "a",
98
- "abbr",
99
- "b",
100
- "blockquote",
101
- "br",
102
- "code",
103
- "em",
104
- "figcaption",
105
- "figure",
106
- "h1",
107
- "h2",
108
- "h3",
109
- "h4",
110
- "h5",
111
- "h6",
112
- "hr",
113
- "i",
114
- "img",
115
- "li",
116
- "ol",
117
- "p",
118
- "pre",
119
- "s",
120
- "span",
121
- "strike",
122
- "strong",
123
- "sub",
124
- "sup",
125
- "table",
126
- "tbody",
127
- "td",
128
- "th",
129
- "thead",
130
- "tr",
131
- "u",
132
- "ul",
133
- "video",
134
- "audio",
135
- "source",
136
- ],
137
- allowedAttributes: {
138
- a: ["href", "title", "rel"],
139
- img: ["src", "alt", "title", "width", "height"],
140
- video: ["src", "poster", "controls", "width", "height"],
141
- audio: ["src", "controls"],
142
- source: ["src", "type"],
143
- "*": ["class"],
144
- },
145
- allowedSchemes: ["http", "https", "mailto"],
146
- };
94
+ // SANITIZE_OPTIONS imported from ../utils/sanitize.js (shared with AP outbox fetcher)
147
95
 
148
96
  /**
149
97
  * Generate unique ID for an item
@@ -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 a hostname resolves to a private/internal address
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
- for (const prefix of BLOCKED_IP_PREFIXES) {
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
- // Block 172.16.0.0/12 (172.16.x.x - 172.31.x.x)
50
- const match172 = hostname.match(/^172\.(\d+)\./);
51
- if (match172) {
52
- const second = Number.parseInt(match172[1], 10);
53
- if (second >= 16 && second <= 31) {
54
- return true;
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
- return `${baseUrl}/microsub/media/${hash}?url=${encodeURIComponent(originalUrl)}`;
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
 
@@ -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 { getChannel } from "../storage/channels.js";
12
+ import { getChannelById } from "../storage/channels.js";
10
13
  import {
11
14
  updateFeed,
12
15
  updateFeedAfterFetch,
@@ -69,11 +72,14 @@ export async function processFeed(application, feed) {
69
72
  }
70
73
 
71
74
  // Get channel for filtering
72
- const channel = await getChannel(application, feed.channelId);
75
+ const channel = await getChannelById(application, feed.channelId);
73
76
 
74
- // Process items
77
+ // Process items (limited to MAX_ITEMS_PER_CYCLE per feed per cycle)
75
78
  let newItemCount = 0;
79
+ let processedCount = 0;
76
80
  for (const item of parsed.items) {
81
+ if (processedCount >= MAX_ITEMS_PER_CYCLE) break;
82
+ processedCount++;
77
83
  // Apply channel filters
78
84
  if (channel?.settings && !passesFilters(item, channel.settings)) {
79
85
  continue;
@@ -276,7 +282,24 @@ export async function processFeedBatch(application, feeds, options = {}) {
276
282
  for (let index = 0; index < feeds.length; index += concurrency) {
277
283
  const batch = feeds.slice(index, index + concurrency);
278
284
  const batchResults = await Promise.all(
279
- batch.map((feed) => processFeed(application, feed)),
285
+ batch.map((feed) =>
286
+ Promise.race([
287
+ processFeed(application, feed),
288
+ new Promise((resolve) =>
289
+ setTimeout(
290
+ () =>
291
+ resolve({
292
+ feedId: feed._id,
293
+ url: feed.url,
294
+ success: false,
295
+ itemsAdded: 0,
296
+ error: "Feed processing timeout",
297
+ }),
298
+ FEED_PROCESS_TIMEOUT,
299
+ ),
300
+ ),
301
+ ]),
302
+ ),
280
303
  );
281
304
  results.push(...batchResults);
282
305
  }
@@ -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;
@@ -62,7 +62,12 @@ export function removeClient(response) {
62
62
  (c) => c.userId === client.userId,
63
63
  );
64
64
  if (!hasOtherClients) {
65
- // Could clean up Redis subscription here if needed
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
  }