@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
package/views/channel.njk CHANGED
@@ -7,6 +7,7 @@
7
7
  <div class="ms-channel__actions">
8
8
  {% if not showRead and items.length > 0 %}
9
9
  <form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
10
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
10
11
  <input type="hidden" name="channel" value="{{ channel.uid }}">
11
12
  <input type="hidden" name="entry" value="last-read-entry">
12
13
  <button type="submit" class="button button--secondary button--small">
@@ -40,6 +41,7 @@
40
41
 
41
42
  {% if items.length > 0 %}
42
43
  <form method="POST" action="{{ baseUrl }}/api/mark-view-read" id="mark-view-form">
44
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
43
45
  <input type="hidden" name="channel" value="{{ channel.uid }}">
44
46
  {% for item in items %}
45
47
  {% if not item._is_read %}
@@ -94,348 +96,5 @@
94
96
  {% endif %}
95
97
  </div>
96
98
 
97
- <script type="module">
98
- // Keyboard navigation (j/k for items, o to open)
99
- const timeline = document.getElementById('timeline');
100
- if (timeline) {
101
- const items = Array.from(timeline.querySelectorAll('.ms-item-card'));
102
- let currentIndex = -1;
103
-
104
- function focusItem(index) {
105
- if (items[currentIndex]) {
106
- items[currentIndex].classList.remove('ms-item-card--focused');
107
- }
108
- currentIndex = Math.max(0, Math.min(index, items.length - 1));
109
- if (items[currentIndex]) {
110
- items[currentIndex].classList.add('ms-item-card--focused');
111
- items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
112
- }
113
- }
114
-
115
- document.addEventListener('keydown', (e) => {
116
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
117
-
118
- switch(e.key) {
119
- case 'j':
120
- e.preventDefault();
121
- focusItem(currentIndex + 1);
122
- break;
123
- case 'k':
124
- e.preventDefault();
125
- focusItem(currentIndex - 1);
126
- break;
127
- case 'o':
128
- case 'Enter':
129
- e.preventDefault();
130
- if (items[currentIndex]) {
131
- const link = items[currentIndex].querySelector('.ms-item-card__link');
132
- if (link) link.click();
133
- }
134
- break;
135
- }
136
- });
137
-
138
- // Handle individual mark-read buttons
139
- const channelUid = timeline.dataset.channel;
140
- // Microsub API is at the parent of /reader (e.g., /microsub not /microsub/reader)
141
- const microsubApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
142
-
143
- timeline.addEventListener('click', async (e) => {
144
- const button = e.target.closest('.ms-item-actions__mark-read');
145
- if (!button) return;
146
-
147
- e.preventDefault();
148
- e.stopPropagation();
149
-
150
- const itemId = button.dataset.itemId;
151
- if (!itemId) return;
152
-
153
- // Disable button while processing
154
- button.disabled = true;
155
-
156
- try {
157
- const formData = new URLSearchParams();
158
- formData.append('action', 'timeline');
159
- formData.append('method', 'mark_read');
160
- formData.append('channel', channelUid);
161
- formData.append('entry', itemId);
162
-
163
- const response = await fetch(microsubApiUrl, {
164
- method: 'POST',
165
- headers: {
166
- 'Content-Type': 'application/x-www-form-urlencoded',
167
- },
168
- body: formData.toString(),
169
- credentials: 'same-origin'
170
- });
171
-
172
- if (response.ok) {
173
- // Hide the item with animation
174
- const card = button.closest('.ms-item-card');
175
- if (card) {
176
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
177
- card.style.opacity = '0';
178
- card.style.transform = 'translateX(-20px)';
179
- setTimeout(() => {
180
- card.remove();
181
- // Check if timeline is now empty
182
- if (timeline.querySelectorAll('.ms-item-card').length === 0) {
183
- location.reload();
184
- }
185
- }, 300);
186
- }
187
- } else {
188
- console.error('Failed to mark item as read');
189
- button.disabled = false;
190
- }
191
- } catch (error) {
192
- console.error('Error marking item as read:', error);
193
- button.disabled = false;
194
- }
195
- });
196
-
197
- // Handle caret toggle for mark-source-read popover
198
- timeline.addEventListener('click', (e) => {
199
- const caret = e.target.closest('.ms-item-actions__mark-read-caret');
200
- if (!caret) return;
201
-
202
- e.preventDefault();
203
- e.stopPropagation();
204
-
205
- // Close other open popovers
206
- for (const p of timeline.querySelectorAll('.ms-item-actions__mark-read-popover:not([hidden])')) {
207
- if (p !== caret.nextElementSibling) p.hidden = true;
208
- }
209
-
210
- const popover = caret.nextElementSibling;
211
- if (popover) popover.hidden = !popover.hidden;
212
- });
213
-
214
- // Handle mark-source-read button
215
- timeline.addEventListener('click', async (e) => {
216
- const button = e.target.closest('.ms-item-actions__mark-source-read');
217
- if (!button) return;
218
-
219
- e.preventDefault();
220
- e.stopPropagation();
221
-
222
- const feedId = button.dataset.feedId;
223
- if (!feedId) return;
224
-
225
- button.disabled = true;
226
-
227
- try {
228
- const formData = new URLSearchParams();
229
- formData.append('action', 'timeline');
230
- formData.append('method', 'mark_read_source');
231
- formData.append('channel', channelUid);
232
- formData.append('feed', feedId);
233
-
234
- const response = await fetch(microsubApiUrl, {
235
- method: 'POST',
236
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
237
- body: formData.toString(),
238
- credentials: 'same-origin'
239
- });
240
-
241
- if (response.ok) {
242
- // Animate out all cards from this feed
243
- const cards = timeline.querySelectorAll(`.ms-item-card[data-feed-id="${feedId}"]`);
244
- for (const card of cards) {
245
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
246
- card.style.opacity = '0';
247
- card.style.transform = 'translateX(-20px)';
248
- }
249
- setTimeout(() => {
250
- for (const card of [...cards]) {
251
- card.remove();
252
- }
253
- if (timeline.querySelectorAll('.ms-item-card').length === 0) {
254
- location.reload();
255
- }
256
- }, 300);
257
- } else {
258
- button.disabled = false;
259
- }
260
- } catch (error) {
261
- console.error('Error marking source as read:', error);
262
- button.disabled = false;
263
- }
264
- });
265
-
266
- // Close popovers on outside click
267
- document.addEventListener('click', (e) => {
268
- if (!e.target.closest('.ms-item-actions__mark-read-group')) {
269
- for (const p of timeline.querySelectorAll('.ms-item-actions__mark-read-popover:not([hidden])')) {
270
- p.hidden = true;
271
- }
272
- }
273
- });
274
-
275
- // Handle save-for-later buttons
276
- timeline.addEventListener('click', async (e) => {
277
- const button = e.target.closest('.ms-item-actions__save-later');
278
- if (!button) return;
279
-
280
- e.preventDefault();
281
- e.stopPropagation();
282
-
283
- const url = button.dataset.url;
284
- const title = button.dataset.title;
285
- if (!url) return;
286
-
287
- button.disabled = true;
288
-
289
- try {
290
- const response = await fetch('/readlater/save', {
291
- method: 'POST',
292
- headers: { 'Content-Type': 'application/json' },
293
- body: JSON.stringify({ url, title: title || url, source: 'microsub' }),
294
- credentials: 'same-origin'
295
- });
296
-
297
- if (response.ok) {
298
- button.classList.add('ms-item-actions__save-later--saved');
299
- button.title = 'Saved';
300
- } else {
301
- button.disabled = false;
302
- }
303
- } catch {
304
- button.disabled = false;
305
- }
306
- });
307
- }
308
-
309
- // === Infinite scroll ===
310
- const loader = document.getElementById('timeline-loader');
311
- if (loader && timeline) {
312
- const spinner = loader.querySelector('.ms-timeline__spinner');
313
- const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
314
- const endMessage = loader.querySelector('.ms-timeline__end');
315
- let cursor = loader.dataset.cursor;
316
- let loading = false;
317
- let hasMore = true;
318
- const apiUrl = loader.dataset.apiUrl;
319
- const showReadParam = loader.dataset.showRead;
320
-
321
- async function loadMore() {
322
- if (loading || !hasMore) return;
323
- loading = true;
324
- if (spinner) spinner.style.display = '';
325
- if (loadMoreLink) loadMoreLink.style.display = 'none';
326
-
327
- try {
328
- const params = new URLSearchParams({ after: cursor });
329
- if (showReadParam === 'true') params.append('showRead', 'true');
330
-
331
- const response = await fetch(`${apiUrl}?${params}`, {
332
- credentials: 'same-origin'
333
- });
334
-
335
- if (!response.ok) throw new Error('Failed to load');
336
-
337
- const data = await response.json();
338
-
339
- if (data.html && data.count > 0) {
340
- timeline.insertAdjacentHTML('beforeend', data.html);
341
- }
342
-
343
- if (data.paging?.after) {
344
- cursor = data.paging.after;
345
- if (loadMoreLink) {
346
- loadMoreLink.href = `?after=${cursor}${showReadParam === 'true' ? '&showRead=true' : ''}`;
347
- loadMoreLink.style.display = '';
348
- }
349
- } else {
350
- hasMore = false;
351
- if (loadMoreLink) loadMoreLink.style.display = 'none';
352
- if (endMessage) endMessage.style.display = '';
353
- }
354
- } catch (error) {
355
- console.error('Infinite scroll error:', error);
356
- hasMore = false;
357
- if (loadMoreLink) loadMoreLink.style.display = '';
358
- } finally {
359
- loading = false;
360
- if (spinner) spinner.style.display = 'none';
361
- }
362
- }
363
-
364
- // IntersectionObserver auto-loads when sentinel is visible
365
- const observer = new IntersectionObserver((entries) => {
366
- if (entries[0].isIntersecting && hasMore && !loading) {
367
- loadMore();
368
- }
369
- }, { rootMargin: '200px' });
370
- observer.observe(loader);
371
-
372
- // Click handler for fallback link
373
- if (loadMoreLink) {
374
- loadMoreLink.addEventListener('click', (e) => {
375
- e.preventDefault();
376
- loadMore();
377
- });
378
- }
379
-
380
- // Expose loadMore for mark-view-as-read to trigger
381
- window.__microsubLoadMore = loadMore;
382
- }
383
-
384
- // === Mark current view as read button ===
385
- const markViewBtn = document.querySelector('.js-mark-view-read');
386
- if (markViewBtn && timeline) {
387
- markViewBtn.style.display = '';
388
- const markViewApiUrl = '{{ baseUrl }}'.replace(/\/reader$/, '');
389
-
390
- markViewBtn.addEventListener('click', async () => {
391
- const unreadCards = timeline.querySelectorAll('.ms-item-card:not(.ms-item-card--read)');
392
- const itemIds = [...unreadCards].map((card) => card.dataset.itemId).filter(Boolean);
393
-
394
- if (itemIds.length === 0) return;
395
-
396
- markViewBtn.disabled = true;
397
-
398
- const formData = new URLSearchParams();
399
- formData.append('action', 'timeline');
400
- formData.append('method', 'mark_read');
401
- formData.append('channel', markViewBtn.dataset.channel);
402
- for (const id of itemIds) {
403
- formData.append('entry', id);
404
- }
405
-
406
- try {
407
- const response = await fetch(markViewApiUrl, {
408
- method: 'POST',
409
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
410
- body: formData.toString(),
411
- credentials: 'same-origin'
412
- });
413
-
414
- if (response.ok) {
415
- for (const card of unreadCards) {
416
- card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
417
- card.style.opacity = '0';
418
- card.style.transform = 'translateX(-20px)';
419
- }
420
- setTimeout(() => {
421
- for (const card of [...unreadCards]) card.remove();
422
- markViewBtn.disabled = false;
423
-
424
- // Trigger infinite scroll to load next batch
425
- if (typeof window.__microsubLoadMore === 'function') {
426
- window.__microsubLoadMore();
427
- } else if (timeline.querySelectorAll('.ms-item-card').length === 0) {
428
- location.reload();
429
- }
430
- }, 300);
431
- } else {
432
- markViewBtn.disabled = false;
433
- }
434
- } catch (error) {
435
- console.error('Error marking current view as read:', error);
436
- markViewBtn.disabled = false;
437
- }
438
- });
439
- }
440
- </script>
99
+ <script type="module" src="/assets/@rmdes-indiekit-endpoint-microsub/reader.js"></script>
441
100
  {% endblock %}
package/views/compose.njk CHANGED
@@ -45,6 +45,7 @@
45
45
  {% endif %}
46
46
 
47
47
  <form method="post" action="{{ baseUrl }}/compose">
48
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
48
49
  {% if replyTo %}
49
50
  <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
50
51
  {% endif %}
@@ -10,6 +10,7 @@
10
10
  </header>
11
11
 
12
12
  <form action="{{ baseUrl }}/deck/settings" method="POST">
13
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
13
14
  <p>Select which channels appear as columns in your deck, and their order.</p>
14
15
 
15
16
  <div class="ms-deck-settings__channels">
@@ -35,6 +35,7 @@
35
35
  </div>
36
36
 
37
37
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit" class="ms-feed-edit__form">
38
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
38
39
  {{ input({
39
40
  id: "url",
40
41
  name: "url",
@@ -64,6 +65,7 @@
64
65
  <h3>Other Actions</h3>
65
66
 
66
67
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" class="ms-feed-edit__action">
68
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
67
69
  <p>Run feed discovery on the current URL to find the actual RSS/Atom feed.</p>
68
70
  {{ button({
69
71
  text: "Rediscover Feed",
@@ -72,6 +74,7 @@
72
74
  </form>
73
75
 
74
76
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" class="ms-feed-edit__action">
77
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
75
78
  <p>Force refresh this feed now.</p>
76
79
  {{ button({
77
80
  text: "Refresh Now",
package/views/feeds.njk CHANGED
@@ -62,16 +62,19 @@
62
62
  {{ icon("updatePost") }}
63
63
  </a>
64
64
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" style="display:inline;">
65
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
65
66
  <button type="submit" class="button button--secondary button--small" title="Rediscover feed">
66
67
  {{ icon("syndicate") }}
67
68
  </button>
68
69
  </form>
69
70
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" style="display:inline;">
71
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
70
72
  <button type="submit" class="button button--secondary button--small" title="Refresh now">
71
73
  {{ icon("repost") }}
72
74
  </button>
73
75
  </form>
74
76
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" style="display:inline;">
77
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
75
78
  <input type="hidden" name="url" value="{{ feed.url }}">
76
79
  <button type="submit" class="button button--warning button--small" title="Unfollow">
77
80
  {{ icon("delete") }}
@@ -91,6 +94,7 @@
91
94
  <div class="ms-feeds__add">
92
95
  <h3>{{ __("microsub.feeds.follow") }}</h3>
93
96
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="ms-feeds__form">
97
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
94
98
  {{ input({
95
99
  id: "url",
96
100
  name: "url",
@@ -6,6 +6,7 @@
6
6
 
7
7
  {% block content %}
8
8
  <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
9
+ <meta name="csrf-token" content="{{ csrfToken }}">
9
10
  {% include "partials/breadcrumbs.njk" %}
10
11
  {% include "partials/view-switcher.njk" %}
11
12
  {% block reader %}{% endblock %}
package/views/search.njk CHANGED
@@ -9,6 +9,7 @@
9
9
  <h2>{{ __("microsub.search.title") }}</h2>
10
10
 
11
11
  <form method="post" action="{{ baseUrl }}/search" class="ms-search__form">
12
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
12
13
  {{ input({
13
14
  id: "query",
14
15
  name: "query",
@@ -60,6 +61,7 @@
60
61
  </div>
61
62
  {% if result.valid %}
62
63
  <form method="post" action="{{ baseUrl }}/subscribe" class="ms-search__subscribe">
64
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
63
65
  <input type="hidden" name="url" value="{{ result.url }}">
64
66
  <label for="channel-{{ loop.index }}" class="-!-visually-hidden">{{ __("microsub.channels.title") }}</label>
65
67
  <select name="channel" id="channel-{{ loop.index }}" class="select select--small">
@@ -9,6 +9,7 @@
9
9
  <h2>{{ __("microsub.settings.title", { channel: channel.name }) }}</h2>
10
10
 
11
11
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/settings">
12
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
12
13
  {{ checkboxes({
13
14
  name: "excludeTypes",
14
15
  values: channel.settings.excludeTypes,
@@ -64,6 +65,7 @@
64
65
  <h3>{{ __("microsub.settings.dangerZone") }}</h3>
65
66
  <p class="hint">{{ __("microsub.settings.deleteWarning") }}</p>
66
67
  <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/delete" onsubmit="return confirm('{{ __("microsub.settings.deleteConfirm") }}');">
68
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
67
69
  {{ button({
68
70
  text: __("microsub.settings.delete"),
69
71
  classes: "button--danger"