@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
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Microsub Reader — client-side JS module
3
+ *
4
+ * Reads configuration from data-* attributes and meta tags:
5
+ * #timeline[data-channel] — channel UID (channel view only)
6
+ * #timeline-loader[data-api-url] — HTML fragment endpoint
7
+ * #timeline-loader[data-cursor] — initial pagination cursor
8
+ * #timeline-loader[data-show-read] — show read items flag ("true"/"false")
9
+ * meta[name="csrf-token"] — CSRF token
10
+ * .js-mark-view-read[data-channel] — mark-view-as-read button (channel view only)
11
+ */
12
+
13
+ // CSRF token for all AJAX requests
14
+ const csrfToken =
15
+ document.querySelector('meta[name="csrf-token"]')?.content || "";
16
+
17
+ const timeline = document.getElementById("timeline");
18
+
19
+ if (timeline) {
20
+ // === Keyboard navigation (j / k / o) ===
21
+ // Q17: use a function so newly loaded items are always included
22
+ function getItems() {
23
+ return Array.from(timeline.querySelectorAll(".ms-item-card"));
24
+ }
25
+
26
+ let currentIndex = -1;
27
+
28
+ function focusItem(index) {
29
+ const items = getItems();
30
+ if (items[currentIndex]) {
31
+ items[currentIndex].classList.remove("ms-item-card--focused");
32
+ }
33
+ currentIndex = Math.max(0, Math.min(index, items.length - 1));
34
+ if (items[currentIndex]) {
35
+ items[currentIndex].classList.add("ms-item-card--focused");
36
+ items[currentIndex].scrollIntoView({ behavior: "smooth", block: "center" });
37
+ }
38
+ }
39
+
40
+ document.addEventListener("keydown", (e) => {
41
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
42
+
43
+ const items = getItems();
44
+ switch (e.key) {
45
+ case "j":
46
+ e.preventDefault();
47
+ focusItem(currentIndex + 1);
48
+ break;
49
+ case "k":
50
+ e.preventDefault();
51
+ focusItem(currentIndex - 1);
52
+ break;
53
+ case "o":
54
+ case "Enter":
55
+ e.preventDefault();
56
+ if (items[currentIndex]) {
57
+ const link = items[currentIndex].querySelector(".ms-item-card__link");
58
+ if (link) link.click();
59
+ }
60
+ break;
61
+ }
62
+ });
63
+
64
+ // Microsub API URL — strip "/reader" suffix so we hit the protocol endpoint
65
+ const microsubApiUrl = (timeline.dataset.apiBase || location.pathname)
66
+ .replace(/\/reader.*$/, "");
67
+
68
+ // === Individual mark-read button ===
69
+ const channelUid = timeline.dataset.channel;
70
+
71
+ timeline.addEventListener("click", async (e) => {
72
+ const button = e.target.closest(".ms-item-actions__mark-read");
73
+ if (!button) return;
74
+
75
+ e.preventDefault();
76
+ e.stopPropagation();
77
+
78
+ const itemId = button.dataset.itemId;
79
+ // In timeline view the channel uid lives on the button; in channel view it's on #timeline
80
+ const effectiveChannel =
81
+ button.dataset.channelUid ||
82
+ button.dataset.channelId ||
83
+ channelUid;
84
+ if (!itemId || !effectiveChannel) return;
85
+
86
+ button.disabled = true;
87
+
88
+ try {
89
+ const formData = new URLSearchParams();
90
+ formData.append("action", "timeline");
91
+ formData.append("method", "mark_read");
92
+ formData.append("channel", effectiveChannel);
93
+ formData.append("entry", itemId);
94
+
95
+ const response = await fetch(microsubApiUrl, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/x-www-form-urlencoded",
99
+ "X-CSRF-Token": csrfToken,
100
+ },
101
+ body: formData.toString(),
102
+ credentials: "same-origin",
103
+ });
104
+
105
+ if (response.ok) {
106
+ const card = button.closest(".ms-item-card");
107
+ if (card) {
108
+ card.style.transition = "opacity 0.3s ease, transform 0.3s ease";
109
+ card.style.opacity = "0";
110
+ card.style.transform = "translateX(-20px)";
111
+ setTimeout(() => {
112
+ // In timeline view items are wrapped in .ms-timeline-view__item
113
+ const wrapper = card.closest(".ms-timeline-view__item");
114
+ if (wrapper) wrapper.remove();
115
+ else card.remove();
116
+ if (timeline.querySelectorAll(".ms-item-card").length === 0) {
117
+ location.reload();
118
+ }
119
+ }, 300);
120
+ }
121
+ } else {
122
+ console.error("Failed to mark item as read");
123
+ button.disabled = false;
124
+ }
125
+ } catch (error) {
126
+ console.error("Error marking item as read:", error);
127
+ button.disabled = false;
128
+ }
129
+ });
130
+
131
+ // === Mark-source-read popover toggle ===
132
+ timeline.addEventListener("click", (e) => {
133
+ const caret = e.target.closest(".ms-item-actions__mark-read-caret");
134
+ if (!caret) return;
135
+
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+
139
+ // Close other open popovers
140
+ for (const p of timeline.querySelectorAll(
141
+ ".ms-item-actions__mark-read-popover:not([hidden])",
142
+ )) {
143
+ if (p !== caret.nextElementSibling) p.hidden = true;
144
+ }
145
+
146
+ const popover = caret.nextElementSibling;
147
+ if (popover) popover.hidden = !popover.hidden;
148
+ });
149
+
150
+ // === Mark-source-read button ===
151
+ timeline.addEventListener("click", async (e) => {
152
+ const button = e.target.closest(".ms-item-actions__mark-source-read");
153
+ if (!button) return;
154
+
155
+ e.preventDefault();
156
+ e.stopPropagation();
157
+
158
+ const feedId = button.dataset.feedId;
159
+ const effectiveChannel =
160
+ button.dataset.channelUid ||
161
+ button.dataset.channelId ||
162
+ channelUid;
163
+ if (!feedId || !effectiveChannel) return;
164
+
165
+ button.disabled = true;
166
+
167
+ try {
168
+ const formData = new URLSearchParams();
169
+ formData.append("action", "timeline");
170
+ formData.append("method", "mark_read_source");
171
+ formData.append("channel", effectiveChannel);
172
+ formData.append("feed", feedId);
173
+
174
+ const response = await fetch(microsubApiUrl, {
175
+ method: "POST",
176
+ headers: {
177
+ "Content-Type": "application/x-www-form-urlencoded",
178
+ "X-CSRF-Token": csrfToken,
179
+ },
180
+ body: formData.toString(),
181
+ credentials: "same-origin",
182
+ });
183
+
184
+ if (response.ok) {
185
+ const cards = timeline.querySelectorAll(
186
+ `.ms-item-card[data-feed-id="${feedId}"]`,
187
+ );
188
+ for (const card of cards) {
189
+ card.style.transition = "opacity 0.3s ease, transform 0.3s ease";
190
+ card.style.opacity = "0";
191
+ card.style.transform = "translateX(-20px)";
192
+ }
193
+ setTimeout(() => {
194
+ for (const card of [...cards]) {
195
+ const wrapper = card.closest(".ms-timeline-view__item");
196
+ if (wrapper) wrapper.remove();
197
+ else card.remove();
198
+ }
199
+ if (timeline.querySelectorAll(".ms-item-card").length === 0) {
200
+ location.reload();
201
+ }
202
+ }, 300);
203
+ } else {
204
+ button.disabled = false;
205
+ }
206
+ } catch (error) {
207
+ console.error("Error marking source as read:", error);
208
+ button.disabled = false;
209
+ }
210
+ });
211
+
212
+ // === Close popovers on outside click ===
213
+ document.addEventListener("click", (e) => {
214
+ if (!e.target.closest(".ms-item-actions__mark-read-group")) {
215
+ for (const p of timeline.querySelectorAll(
216
+ ".ms-item-actions__mark-read-popover:not([hidden])",
217
+ )) {
218
+ p.hidden = true;
219
+ }
220
+ }
221
+ });
222
+
223
+ // === Save-for-later ===
224
+ timeline.addEventListener("click", async (e) => {
225
+ const button = e.target.closest(".ms-item-actions__save-later");
226
+ if (!button) return;
227
+
228
+ e.preventDefault();
229
+ e.stopPropagation();
230
+
231
+ const url = button.dataset.url;
232
+ const title = button.dataset.title;
233
+ if (!url) return;
234
+
235
+ button.disabled = true;
236
+
237
+ try {
238
+ const response = await fetch("/readlater/save", {
239
+ method: "POST",
240
+ headers: {
241
+ "Content-Type": "application/json",
242
+ "X-CSRF-Token": csrfToken,
243
+ },
244
+ body: JSON.stringify({ url, title: title || url, source: "microsub" }),
245
+ credentials: "same-origin",
246
+ });
247
+
248
+ if (response.ok) {
249
+ button.classList.add("ms-item-actions__save-later--saved");
250
+ button.title = "Saved";
251
+ } else {
252
+ button.disabled = false;
253
+ }
254
+ } catch {
255
+ button.disabled = false;
256
+ }
257
+ });
258
+ }
259
+
260
+ // === Infinite scroll ===
261
+ const loader = document.getElementById("timeline-loader");
262
+ if (loader && timeline) {
263
+ const spinner = loader.querySelector(".ms-timeline__spinner");
264
+ const loadMoreLink = loader.querySelector(".ms-timeline__load-more");
265
+ const endMessage = loader.querySelector(".ms-timeline__end");
266
+ let cursor = loader.dataset.cursor;
267
+ let loading = false;
268
+ let hasMore = true;
269
+ const apiUrl = loader.dataset.apiUrl;
270
+ const showReadParam = loader.dataset.showRead;
271
+
272
+ async function loadMore() {
273
+ if (loading || !hasMore) return;
274
+ loading = true;
275
+ if (spinner) spinner.style.display = "";
276
+ if (loadMoreLink) loadMoreLink.style.display = "none";
277
+
278
+ try {
279
+ // Build query: some apiUrls already include a query string (timeline view)
280
+ const sep = apiUrl.includes("?") ? "&" : "?";
281
+ let query = `${sep}after=${encodeURIComponent(cursor)}`;
282
+ if (showReadParam === "true") query += "&showRead=true";
283
+
284
+ const response = await fetch(`${apiUrl}${query}`, {
285
+ credentials: "same-origin",
286
+ });
287
+
288
+ if (!response.ok) throw new Error("Failed to load");
289
+
290
+ const data = await response.json();
291
+
292
+ if (data.html && data.count > 0) {
293
+ timeline.insertAdjacentHTML("beforeend", data.html);
294
+ }
295
+
296
+ if (data.paging?.after) {
297
+ cursor = data.paging.after;
298
+ if (loadMoreLink) {
299
+ loadMoreLink.href = `?after=${cursor}${showReadParam === "true" ? "&showRead=true" : ""}`;
300
+ loadMoreLink.style.display = "";
301
+ }
302
+ } else {
303
+ hasMore = false;
304
+ if (loadMoreLink) loadMoreLink.style.display = "none";
305
+ if (endMessage) endMessage.style.display = "";
306
+ }
307
+ } catch (error) {
308
+ console.error("Infinite scroll error:", error);
309
+ hasMore = false;
310
+ if (loadMoreLink) loadMoreLink.style.display = "";
311
+ } finally {
312
+ loading = false;
313
+ if (spinner) spinner.style.display = "none";
314
+ }
315
+ }
316
+
317
+ // Q16: listen for CustomEvent instead of exposing window global
318
+ document.addEventListener("microsub:trigger-load", () => loadMore());
319
+
320
+ // IntersectionObserver auto-loads when sentinel is visible
321
+ const observer = new IntersectionObserver(
322
+ (entries) => {
323
+ if (entries[0].isIntersecting && hasMore && !loading) {
324
+ loadMore();
325
+ }
326
+ },
327
+ { rootMargin: "200px" },
328
+ );
329
+ observer.observe(loader);
330
+
331
+ // Click handler for fallback link
332
+ if (loadMoreLink) {
333
+ loadMoreLink.addEventListener("click", (e) => {
334
+ e.preventDefault();
335
+ loadMore();
336
+ });
337
+ }
338
+ }
339
+
340
+ // === Mark current view as read (channel view only) ===
341
+ const markViewBtn = document.querySelector(".js-mark-view-read");
342
+ if (markViewBtn && timeline) {
343
+ // Show the button (hidden by default for noscript compat)
344
+ markViewBtn.style.display = "";
345
+
346
+ // Derive Microsub API URL from current path
347
+ const markViewApiUrl = location.pathname.replace(/\/reader.*$/, "");
348
+
349
+ markViewBtn.addEventListener("click", async () => {
350
+ const unreadCards = timeline.querySelectorAll(
351
+ ".ms-item-card:not(.ms-item-card--read)",
352
+ );
353
+ const itemIds = [...unreadCards]
354
+ .map((card) => card.dataset.itemId)
355
+ .filter(Boolean);
356
+
357
+ if (itemIds.length === 0) return;
358
+
359
+ markViewBtn.disabled = true;
360
+
361
+ const formData = new URLSearchParams();
362
+ formData.append("action", "timeline");
363
+ formData.append("method", "mark_read");
364
+ formData.append("channel", markViewBtn.dataset.channel);
365
+ for (const id of itemIds) {
366
+ formData.append("entry", id);
367
+ }
368
+
369
+ try {
370
+ const response = await fetch(markViewApiUrl, {
371
+ method: "POST",
372
+ headers: {
373
+ "Content-Type": "application/x-www-form-urlencoded",
374
+ "X-CSRF-Token": csrfToken,
375
+ },
376
+ body: formData.toString(),
377
+ credentials: "same-origin",
378
+ });
379
+
380
+ if (response.ok) {
381
+ for (const card of unreadCards) {
382
+ card.style.transition = "opacity 0.3s ease, transform 0.3s ease";
383
+ card.style.opacity = "0";
384
+ card.style.transform = "translateX(-20px)";
385
+ }
386
+ setTimeout(() => {
387
+ for (const card of [...unreadCards]) card.remove();
388
+ markViewBtn.disabled = false;
389
+
390
+ // Q16: dispatch CustomEvent to trigger infinite scroll load
391
+ document.dispatchEvent(new CustomEvent("microsub:trigger-load"));
392
+
393
+ if (
394
+ !document.getElementById("timeline-loader") &&
395
+ timeline.querySelectorAll(".ms-item-card").length === 0
396
+ ) {
397
+ location.reload();
398
+ }
399
+ }, 300);
400
+ } else {
401
+ markViewBtn.disabled = false;
402
+ }
403
+ } catch (error) {
404
+ console.error("Error marking current view as read:", error);
405
+ markViewBtn.disabled = false;
406
+ }
407
+ });
408
+ }
package/index.js CHANGED
@@ -2,18 +2,21 @@ 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
- import { readerController } from "./lib/controllers/reader.js";
9
+ import { readerController } from "./lib/controllers/reader/index.js";
10
+ import { asyncHandler } from "./lib/utils/async-handler.js";
9
11
  import { handleMediaProxy } from "./lib/media/proxy.js";
12
+ import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
10
13
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
11
14
  import { ensureActivityPubChannel } from "./lib/storage/channels.js";
15
+ import { createIndexes } from "./lib/storage/items.js";
12
16
  import {
13
17
  cleanupAllReadItems,
14
18
  cleanupStaleItems,
15
- createIndexes,
16
- } from "./lib/storage/items.js";
19
+ } from "./lib/storage/items-retention.js";
17
20
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
18
21
  import { websubHandler } from "./lib/websub/handler.js";
19
22
 
@@ -77,72 +80,69 @@ export default class MicrosubEndpoint {
77
80
  router.get("/", microsubController.get);
78
81
  router.post("/", microsubController.post);
79
82
 
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);
83
+ // WebSub, webmention, and media proxy are registered in routesPublic only
84
+ // (they must be accessible without authentication)
89
85
 
90
86
  // Reader UI routes (mounted as sub-router for correct baseUrl)
91
- readerRouter.get("/", readerController.index);
92
- readerRouter.get("/channels", readerController.channels);
93
- readerRouter.get("/channels/new", readerController.newChannel);
94
- readerRouter.post("/channels/new", readerController.createChannel);
95
- readerRouter.get("/channels/:uid/html", readerController.channelHtml);
96
- readerRouter.get("/channels/:uid", readerController.channel);
97
- readerRouter.get("/channels/:uid/settings", readerController.settings);
87
+ // CSRF protection: generate token on all requests, validate on POST
88
+ readerRouter.use(csrfToken);
89
+ readerRouter.use(csrfValidate);
90
+
91
+ readerRouter.get("/", asyncHandler(readerController.index));
92
+ readerRouter.get("/channels", asyncHandler(readerController.channels));
93
+ readerRouter.get("/channels/new", asyncHandler(readerController.newChannel));
94
+ readerRouter.post("/channels/new", asyncHandler(readerController.createChannel));
95
+ readerRouter.get("/channels/:uid/html", asyncHandler(readerController.channelHtml));
96
+ readerRouter.get("/channels/:uid", asyncHandler(readerController.channel));
97
+ readerRouter.get("/channels/:uid/settings", asyncHandler(readerController.settings));
98
98
  readerRouter.post(
99
99
  "/channels/:uid/settings",
100
- readerController.updateSettings,
100
+ asyncHandler(readerController.updateSettings),
101
101
  );
102
- readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
103
- readerRouter.get("/channels/:uid/feeds", readerController.feeds);
104
- readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
102
+ readerRouter.post("/channels/:uid/delete", asyncHandler(readerController.deleteChannel));
103
+ readerRouter.get("/channels/:uid/feeds", asyncHandler(readerController.feeds));
104
+ readerRouter.post("/channels/:uid/feeds", asyncHandler(readerController.addFeed));
105
105
  readerRouter.post(
106
106
  "/channels/:uid/feeds/remove",
107
- readerController.removeFeed,
107
+ asyncHandler(readerController.removeFeed),
108
108
  );
109
109
  readerRouter.get(
110
110
  "/channels/:uid/feeds/:feedId",
111
- readerController.feedDetails,
111
+ asyncHandler(readerController.feedDetails),
112
112
  );
113
113
  readerRouter.get(
114
114
  "/channels/:uid/feeds/:feedId/edit",
115
- readerController.editFeedForm,
115
+ asyncHandler(readerController.editFeedForm),
116
116
  );
117
117
  readerRouter.post(
118
118
  "/channels/:uid/feeds/:feedId/edit",
119
- readerController.updateFeedUrl,
119
+ asyncHandler(readerController.updateFeedUrl),
120
120
  );
121
121
  readerRouter.post(
122
122
  "/channels/:uid/feeds/:feedId/rediscover",
123
- readerController.rediscoverFeed,
123
+ asyncHandler(readerController.rediscoverFeed),
124
124
  );
125
125
  readerRouter.post(
126
126
  "/channels/:uid/feeds/:feedId/refresh",
127
- readerController.refreshFeed,
127
+ asyncHandler(readerController.refreshFeed),
128
128
  );
129
- readerRouter.get("/item/:id", readerController.item);
130
- readerRouter.get("/compose", readerController.compose);
131
- readerRouter.post("/compose", readerController.submitCompose);
132
- readerRouter.get("/search", readerController.searchPage);
133
- readerRouter.post("/search", readerController.searchFeeds);
134
- readerRouter.post("/subscribe", readerController.subscribe);
135
- readerRouter.get("/actor", readerController.actorProfile);
136
- readerRouter.post("/actor/follow", readerController.followActorAction);
137
- readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
138
- readerRouter.post("/api/mark-read", readerController.markAllRead);
139
- readerRouter.post("/api/mark-view-read", readerController.markViewRead);
129
+ readerRouter.get("/item/:id", asyncHandler(readerController.item));
130
+ readerRouter.get("/compose", asyncHandler(readerController.compose));
131
+ readerRouter.post("/compose", asyncHandler(readerController.submitCompose));
132
+ readerRouter.get("/search", asyncHandler(readerController.searchPage));
133
+ readerRouter.post("/search", asyncHandler(readerController.searchFeeds));
134
+ readerRouter.post("/subscribe", asyncHandler(readerController.subscribe));
135
+ readerRouter.get("/actor", asyncHandler(readerController.actorProfile));
136
+ readerRouter.post("/actor/follow", asyncHandler(readerController.followActorAction));
137
+ readerRouter.post("/actor/unfollow", asyncHandler(readerController.unfollowActorAction));
138
+ readerRouter.post("/api/mark-read", asyncHandler(readerController.markAllRead));
139
+ readerRouter.post("/api/mark-view-read", asyncHandler(readerController.markViewRead));
140
140
  readerRouter.get("/opml", opmlController.exportOpml);
141
- readerRouter.get("/timeline/html", readerController.timelineHtml);
142
- readerRouter.get("/timeline", readerController.timeline);
143
- readerRouter.get("/deck", readerController.deck);
144
- readerRouter.get("/deck/settings", readerController.deckSettings);
145
- readerRouter.post("/deck/settings", readerController.saveDeckSettings);
141
+ readerRouter.get("/timeline/html", asyncHandler(readerController.timelineHtml));
142
+ readerRouter.get("/timeline", asyncHandler(readerController.timeline));
143
+ readerRouter.get("/deck", asyncHandler(readerController.deck));
144
+ readerRouter.get("/deck/settings", asyncHandler(readerController.deckSettings));
145
+ readerRouter.post("/deck/settings", asyncHandler(readerController.saveDeckSettings));
146
146
  router.use("/reader", readerRouter);
147
147
 
148
148
  return router;
@@ -155,15 +155,20 @@ export default class MicrosubEndpoint {
155
155
  get routesPublic() {
156
156
  const publicRouter = express.Router();
157
157
 
158
+ // Rate limiters for public endpoints
159
+ const mediaLimiter = rateLimit({ windowMs: 60_000, max: 120, message: "Too many requests" });
160
+ const websubLimiter = rateLimit({ windowMs: 60_000, max: 30, message: "Too many requests" });
161
+ const webmentionLimiter = rateLimit({ windowMs: 60_000, max: 10, message: "Too many requests" });
162
+
158
163
  // WebSub verification must be public for hubs to verify
159
- publicRouter.get("/websub/:id", websubHandler.verify);
160
- publicRouter.post("/websub/:id", websubHandler.receive);
164
+ publicRouter.get("/websub/:id", websubLimiter, websubHandler.verify);
165
+ publicRouter.post("/websub/:id", websubLimiter, websubHandler.receive);
161
166
 
162
167
  // Webmention endpoint must be public
163
- publicRouter.post("/webmention", webmentionReceiver.receive);
168
+ publicRouter.post("/webmention", webmentionLimiter, webmentionReceiver.receive);
164
169
 
165
170
  // Media proxy must be public for images to load
166
- publicRouter.get("/media/:hash", handleMediaProxy);
171
+ publicRouter.get("/media/:hash", mediaLimiter, handleMediaProxy);
167
172
 
168
173
  return publicRouter;
169
174
  }
@@ -222,6 +227,13 @@ export default class MicrosubEndpoint {
222
227
  cleanupStaleItems(indiekit).catch((error) => {
223
228
  console.warn("[Microsub] Stale cleanup failed:", error.message);
224
229
  });
230
+
231
+ // Schedule daily stale cleanup (items accumulate between restarts)
232
+ setInterval(() => {
233
+ cleanupStaleItems(indiekit).catch((error) => {
234
+ console.warn("[Microsub] Scheduled stale cleanup failed:", error.message);
235
+ });
236
+ }, 24 * 60 * 60 * 1000);
225
237
  } else {
226
238
  console.warn(
227
239
  "[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