@rmdes/indiekit-endpoint-microsub 1.0.56 → 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 +37 -36
- package/lib/cache/redis.js +12 -3
- 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/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- 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 +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- 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 +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- 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 +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- package/lib/controllers/reader.js +0 -1562
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
|
@@ -6,16 +6,17 @@ import rateLimit from "express-rate-limit";
|
|
|
6
6
|
|
|
7
7
|
import { microsubController } from "./lib/controllers/microsub.js";
|
|
8
8
|
import { opmlController } from "./lib/controllers/opml.js";
|
|
9
|
-
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";
|
|
10
11
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
11
12
|
import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
|
|
12
13
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
13
14
|
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
15
|
+
import { createIndexes } from "./lib/storage/items.js";
|
|
14
16
|
import {
|
|
15
17
|
cleanupAllReadItems,
|
|
16
18
|
cleanupStaleItems,
|
|
17
|
-
|
|
18
|
-
} from "./lib/storage/items.js";
|
|
19
|
+
} from "./lib/storage/items-retention.js";
|
|
19
20
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
20
21
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
21
22
|
|
|
@@ -87,61 +88,61 @@ export default class MicrosubEndpoint {
|
|
|
87
88
|
readerRouter.use(csrfToken);
|
|
88
89
|
readerRouter.use(csrfValidate);
|
|
89
90
|
|
|
90
|
-
readerRouter.get("/", readerController.index);
|
|
91
|
-
readerRouter.get("/channels", readerController.channels);
|
|
92
|
-
readerRouter.get("/channels/new", readerController.newChannel);
|
|
93
|
-
readerRouter.post("/channels/new", readerController.createChannel);
|
|
94
|
-
readerRouter.get("/channels/:uid/html", readerController.channelHtml);
|
|
95
|
-
readerRouter.get("/channels/:uid", readerController.channel);
|
|
96
|
-
readerRouter.get("/channels/:uid/settings", readerController.settings);
|
|
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));
|
|
97
98
|
readerRouter.post(
|
|
98
99
|
"/channels/:uid/settings",
|
|
99
|
-
readerController.updateSettings,
|
|
100
|
+
asyncHandler(readerController.updateSettings),
|
|
100
101
|
);
|
|
101
|
-
readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
|
|
102
|
-
readerRouter.get("/channels/:uid/feeds", readerController.feeds);
|
|
103
|
-
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));
|
|
104
105
|
readerRouter.post(
|
|
105
106
|
"/channels/:uid/feeds/remove",
|
|
106
|
-
readerController.removeFeed,
|
|
107
|
+
asyncHandler(readerController.removeFeed),
|
|
107
108
|
);
|
|
108
109
|
readerRouter.get(
|
|
109
110
|
"/channels/:uid/feeds/:feedId",
|
|
110
|
-
readerController.feedDetails,
|
|
111
|
+
asyncHandler(readerController.feedDetails),
|
|
111
112
|
);
|
|
112
113
|
readerRouter.get(
|
|
113
114
|
"/channels/:uid/feeds/:feedId/edit",
|
|
114
|
-
readerController.editFeedForm,
|
|
115
|
+
asyncHandler(readerController.editFeedForm),
|
|
115
116
|
);
|
|
116
117
|
readerRouter.post(
|
|
117
118
|
"/channels/:uid/feeds/:feedId/edit",
|
|
118
|
-
readerController.updateFeedUrl,
|
|
119
|
+
asyncHandler(readerController.updateFeedUrl),
|
|
119
120
|
);
|
|
120
121
|
readerRouter.post(
|
|
121
122
|
"/channels/:uid/feeds/:feedId/rediscover",
|
|
122
|
-
readerController.rediscoverFeed,
|
|
123
|
+
asyncHandler(readerController.rediscoverFeed),
|
|
123
124
|
);
|
|
124
125
|
readerRouter.post(
|
|
125
126
|
"/channels/:uid/feeds/:feedId/refresh",
|
|
126
|
-
readerController.refreshFeed,
|
|
127
|
+
asyncHandler(readerController.refreshFeed),
|
|
127
128
|
);
|
|
128
|
-
readerRouter.get("/item/:id", readerController.item);
|
|
129
|
-
readerRouter.get("/compose", readerController.compose);
|
|
130
|
-
readerRouter.post("/compose", readerController.submitCompose);
|
|
131
|
-
readerRouter.get("/search", readerController.searchPage);
|
|
132
|
-
readerRouter.post("/search", readerController.searchFeeds);
|
|
133
|
-
readerRouter.post("/subscribe", readerController.subscribe);
|
|
134
|
-
readerRouter.get("/actor", readerController.actorProfile);
|
|
135
|
-
readerRouter.post("/actor/follow", readerController.followActorAction);
|
|
136
|
-
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
|
137
|
-
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
|
138
|
-
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));
|
|
139
140
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
140
|
-
readerRouter.get("/timeline/html", readerController.timelineHtml);
|
|
141
|
-
readerRouter.get("/timeline", readerController.timeline);
|
|
142
|
-
readerRouter.get("/deck", readerController.deck);
|
|
143
|
-
readerRouter.get("/deck/settings", readerController.deckSettings);
|
|
144
|
-
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));
|
|
145
146
|
router.use("/reader", readerRouter);
|
|
146
147
|
|
|
147
148
|
return router;
|
package/lib/cache/redis.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* @module cache/redis
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// Redis is dynamically imported only when needed so the package is optional
|
|
7
|
+
let Redis;
|
|
8
8
|
let redisClient;
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -12,7 +12,7 @@ let redisClient;
|
|
|
12
12
|
* @param {object} application - Indiekit application
|
|
13
13
|
* @returns {object|undefined} Redis client or undefined
|
|
14
14
|
*/
|
|
15
|
-
export function getRedisClient(application) {
|
|
15
|
+
export async function getRedisClient(application) {
|
|
16
16
|
// Check if Redis is already initialized on the application
|
|
17
17
|
if (application.redis) {
|
|
18
18
|
return application.redis;
|
|
@@ -27,6 +27,15 @@ export function getRedisClient(application) {
|
|
|
27
27
|
const redisUrl = application.config?.application?.redisUrl;
|
|
28
28
|
if (redisUrl) {
|
|
29
29
|
try {
|
|
30
|
+
if (!Redis) {
|
|
31
|
+
try {
|
|
32
|
+
Redis = (await import("ioredis")).default;
|
|
33
|
+
} catch {
|
|
34
|
+
console.warn("[Microsub] ioredis not available");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
redisClient = new Redis(redisUrl, {
|
|
31
40
|
maxRetriesPerRequest: 3,
|
|
32
41
|
retryStrategy(times) {
|