@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/assets/reader.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
92
|
-
readerRouter.
|
|
93
|
-
readerRouter.
|
|
94
|
-
|
|
95
|
-
readerRouter.get("/
|
|
96
|
-
readerRouter.get("/channels
|
|
97
|
-
readerRouter.get("/channels
|
|
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
|
|
125
|
-
const
|
|
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
|
|