@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.
Files changed (75) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +61 -49
  3. package/lib/activitypub/outbox-fetcher.js +14 -2
  4. package/lib/cache/redis.js +26 -7
  5. package/lib/controllers/channels.js +2 -2
  6. package/lib/controllers/reader/actor.js +142 -0
  7. package/lib/controllers/reader/channel.js +301 -0
  8. package/lib/controllers/reader/compose.js +242 -0
  9. package/lib/controllers/reader/deck.js +129 -0
  10. package/lib/controllers/reader/feed-repair.js +117 -0
  11. package/lib/controllers/reader/feed.js +246 -0
  12. package/lib/controllers/reader/index.js +126 -0
  13. package/lib/controllers/reader/search.js +157 -0
  14. package/lib/controllers/reader/timeline.js +250 -0
  15. package/lib/controllers/search.js +6 -0
  16. package/lib/controllers/timeline.js +6 -4
  17. package/lib/feeds/atom.js +1 -1
  18. package/lib/feeds/capabilities.js +5 -0
  19. package/lib/feeds/fetcher.js +5 -28
  20. package/lib/feeds/hfeed.js +1 -1
  21. package/lib/feeds/jsonfeed.js +1 -1
  22. package/lib/feeds/normalizer-hfeed.js +209 -0
  23. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  24. package/lib/feeds/normalizer-rss.js +178 -0
  25. package/lib/feeds/normalizer.js +22 -614
  26. package/lib/feeds/rss.js +1 -1
  27. package/lib/media/proxy.js +82 -27
  28. package/lib/polling/processor.js +30 -21
  29. package/lib/polling/scheduler.js +2 -0
  30. package/lib/realtime/broker.js +6 -1
  31. package/lib/storage/channels.js +53 -42
  32. package/lib/storage/feeds.js +3 -1
  33. package/lib/storage/items-read-state.js +287 -0
  34. package/lib/storage/items-retention.js +174 -0
  35. package/lib/storage/items-search.js +34 -0
  36. package/lib/storage/items.js +113 -610
  37. package/lib/storage/read-state.js +1 -1
  38. package/lib/utils/async-handler.js +7 -0
  39. package/lib/utils/constants.js +7 -0
  40. package/lib/utils/csrf.js +51 -0
  41. package/lib/utils/html.js +25 -0
  42. package/lib/utils/sanitize.js +61 -0
  43. package/lib/utils/source-type.js +28 -0
  44. package/lib/utils/validation.js +8 -2
  45. package/lib/webmention/processor.js +1 -1
  46. package/lib/webmention/verifier.js +10 -21
  47. package/lib/websub/subscriber.js +12 -0
  48. package/locales/de.json +3 -0
  49. package/locales/en.json +2 -0
  50. package/locales/es-419.json +3 -0
  51. package/locales/es.json +3 -0
  52. package/locales/fr.json +3 -0
  53. package/locales/hi.json +3 -0
  54. package/locales/id.json +3 -0
  55. package/locales/it.json +3 -0
  56. package/locales/nl.json +3 -0
  57. package/locales/pl.json +3 -0
  58. package/locales/pt-BR.json +3 -0
  59. package/locales/pt.json +3 -0
  60. package/locales/sr.json +3 -0
  61. package/locales/sv.json +3 -0
  62. package/locales/zh-Hans-CN.json +3 -0
  63. package/package.json +3 -1
  64. package/views/actor.njk +2 -0
  65. package/views/channel-new.njk +1 -0
  66. package/views/channel.njk +3 -344
  67. package/views/compose.njk +1 -0
  68. package/views/deck-settings.njk +1 -0
  69. package/views/feed-edit.njk +3 -0
  70. package/views/feeds.njk +4 -0
  71. package/views/layouts/reader.njk +1 -0
  72. package/views/search.njk +2 -0
  73. package/views/settings.njk +2 -0
  74. package/views/timeline.njk +3 -271
  75. package/lib/controllers/reader.js +0 -1580
@@ -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,
@@ -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 getChannel(application, feed.channelId);
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 = inferSourceType(feed.url);
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) => processFeed(application, 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
  }
@@ -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
  }
@@ -112,8 +112,35 @@ export async function createChannel(application, { name, userId }) {
112
112
  return channel;
113
113
  }
114
114
 
115
- // Retention period for unread count (only count recent items)
116
- const UNREAD_RETENTION_DAYS = 30;
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
- // Calculate cutoff date for unread counts (only count recent items)
137
- const cutoffDate = new Date();
138
- cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
139
-
140
- // Get unread counts for each channel (only recent items)
141
- const channelsWithCounts = await Promise.all(
142
- channels.map(async (channel) => {
143
- const unreadCount = await itemsCollection.countDocuments({
144
- channelId: channel._id,
145
- readBy: { $ne: userId },
146
- published: { $gte: cutoffDate },
147
- _stripped: { $ne: true },
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
- const cutoffDate = new Date();
193
- cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
194
-
195
- const enriched = await Promise.all(
196
- channels.map(async (channel, index) => {
197
- const unreadCount = await itemsCollection.countDocuments({
198
- channelId: channel._id,
199
- readBy: { $ne: userId },
200
- published: { $gte: cutoffDate },
201
- _stripped: { $ne: true },
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");
@@ -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