@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,246 @@
1
+ /**
2
+ * Feed management
3
+ * @module controllers/reader/feed
4
+ */
5
+
6
+ import { validateFeedUrl } from "../../feeds/validator.js";
7
+ import { refreshFeedNow } from "../../polling/scheduler.js";
8
+ import { getChannel } from "../../storage/channels.js";
9
+ import {
10
+ getFeedsForChannel,
11
+ getFeedById,
12
+ createFeed,
13
+ deleteFeed,
14
+ updateFeed,
15
+ } from "../../storage/feeds.js";
16
+ import { getUserId } from "../../utils/auth.js";
17
+
18
+ export { rediscoverFeed, refreshFeed } from "./feed-repair.js";
19
+
20
+ /**
21
+ * View feeds for a channel
22
+ * @param {object} request - Express request
23
+ * @param {object} response - Express response
24
+ * @returns {Promise<void>}
25
+ */
26
+ export async function feeds(request, response) {
27
+ const { application } = request.app.locals;
28
+ const userId = getUserId(request);
29
+ const { uid } = request.params;
30
+
31
+ const channelDocument = await getChannel(application, uid, userId);
32
+ if (!channelDocument) {
33
+ return response.status(404).render("404");
34
+ }
35
+
36
+ const feedList = await getFeedsForChannel(application, channelDocument._id);
37
+
38
+ response.render("feeds", {
39
+ title: request.__("microsub.feeds.title"),
40
+ channel: channelDocument,
41
+ feeds: feedList,
42
+ baseUrl: request.baseUrl,
43
+ readerBaseUrl: request.baseUrl,
44
+ activeView: "channels",
45
+ breadcrumbs: [
46
+ { text: "Reader", href: request.baseUrl },
47
+ { text: "Channels", href: `${request.baseUrl}/channels` },
48
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
49
+ { text: "Feeds" },
50
+ ],
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Add feed to channel
56
+ * @param {object} request - Express request
57
+ * @param {object} response - Express response
58
+ * @returns {Promise<void>}
59
+ */
60
+ export async function addFeed(request, response) {
61
+ const { application } = request.app.locals;
62
+ const userId = getUserId(request);
63
+ const { uid } = request.params;
64
+ const { url } = request.body;
65
+
66
+ const channelDocument = await getChannel(application, uid, userId);
67
+ if (!channelDocument) {
68
+ return response.status(404).render("404");
69
+ }
70
+
71
+ try {
72
+ // Create feed subscription (throws DUPLICATE_FEED if already exists)
73
+ const feed = await createFeed(application, {
74
+ channelId: channelDocument._id,
75
+ url,
76
+ title: undefined,
77
+ photo: undefined,
78
+ });
79
+
80
+ // Trigger immediate fetch in background
81
+ refreshFeedNow(application, feed._id).catch((error) => {
82
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
83
+ });
84
+
85
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
86
+ } catch (error) {
87
+ if (error.code === "DUPLICATE_FEED") {
88
+ // Re-render feeds page with error message
89
+ const feedList = await getFeedsForChannel(application, channelDocument._id);
90
+ return response.render("feeds", {
91
+ title: request.__("microsub.feeds.title"),
92
+ channel: channelDocument,
93
+ feeds: feedList,
94
+ baseUrl: request.baseUrl,
95
+ readerBaseUrl: request.baseUrl,
96
+ activeView: "channels",
97
+ error: `This feed already exists in channel "${error.channelName}"`,
98
+ breadcrumbs: [
99
+ { text: "Reader", href: request.baseUrl },
100
+ { text: "Channels", href: `${request.baseUrl}/channels` },
101
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
102
+ { text: "Feeds" },
103
+ ],
104
+ });
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Remove feed from channel
112
+ * @param {object} request - Express request
113
+ * @param {object} response - Express response
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function removeFeed(request, response) {
117
+ const { application } = request.app.locals;
118
+ const userId = getUserId(request);
119
+ const { uid } = request.params;
120
+ const { url } = request.body;
121
+
122
+ const channelDocument = await getChannel(application, uid, userId);
123
+ if (!channelDocument) {
124
+ return response.status(404).render("404");
125
+ }
126
+
127
+ await deleteFeed(application, channelDocument._id, url);
128
+
129
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
130
+ }
131
+
132
+ /**
133
+ * View single feed details with status - redirects to edit form
134
+ * @param {object} request - Express request
135
+ * @param {object} response - Express response
136
+ * @returns {Promise<void>}
137
+ */
138
+ export async function feedDetails(request, response) {
139
+ const { uid, feedId } = request.params;
140
+ // Redirect to edit form which shows all details
141
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
142
+ }
143
+
144
+ /**
145
+ * Edit feed URL form
146
+ * @param {object} request - Express request
147
+ * @param {object} response - Express response
148
+ * @returns {Promise<void>}
149
+ */
150
+ export async function editFeedForm(request, response) {
151
+ const { application } = request.app.locals;
152
+ const userId = getUserId(request);
153
+ const { uid, feedId } = request.params;
154
+
155
+ const channelDocument = await getChannel(application, uid, userId);
156
+ if (!channelDocument) {
157
+ return response.status(404).render("404");
158
+ }
159
+
160
+ const feed = await getFeedById(application, feedId);
161
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
162
+ return response.status(404).render("404");
163
+ }
164
+
165
+ response.render("feed-edit", {
166
+ title: request.__("microsub.feeds.edit"),
167
+ channel: channelDocument,
168
+ feed,
169
+ baseUrl: request.baseUrl,
170
+ readerBaseUrl: request.baseUrl,
171
+ activeView: "channels",
172
+ breadcrumbs: [
173
+ { text: "Reader", href: request.baseUrl },
174
+ { text: "Channels", href: `${request.baseUrl}/channels` },
175
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
176
+ { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
177
+ { text: "Edit" },
178
+ ],
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Update feed URL
184
+ * @param {object} request - Express request
185
+ * @param {object} response - Express response
186
+ * @returns {Promise<void>}
187
+ */
188
+ export async function updateFeedUrl(request, response) {
189
+ const { application } = request.app.locals;
190
+ const userId = getUserId(request);
191
+ const { uid, feedId } = request.params;
192
+ const { url: newUrl } = request.body;
193
+
194
+ const channelDocument = await getChannel(application, uid, userId);
195
+ if (!channelDocument) {
196
+ return response.status(404).render("404");
197
+ }
198
+
199
+ const feed = await getFeedById(application, feedId);
200
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
201
+ return response.status(404).render("404");
202
+ }
203
+
204
+ // Validate the new URL is a valid feed
205
+ const validation = await validateFeedUrl(newUrl);
206
+
207
+ if (!validation.valid) {
208
+ return response.render("feed-edit", {
209
+ title: request.__("microsub.feeds.edit"),
210
+ channel: channelDocument,
211
+ feed,
212
+ error: validation.error,
213
+ baseUrl: request.baseUrl,
214
+ readerBaseUrl: request.baseUrl,
215
+ activeView: "channels",
216
+ breadcrumbs: [
217
+ { text: "Reader", href: request.baseUrl },
218
+ { text: "Channels", href: `${request.baseUrl}/channels` },
219
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
220
+ { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
221
+ { text: "Edit" },
222
+ ],
223
+ });
224
+ }
225
+
226
+ // Update the feed URL and reset error state
227
+ await updateFeed(application, feedId, {
228
+ url: newUrl,
229
+ title: validation.title || feed.title,
230
+ status: "active",
231
+ lastError: undefined,
232
+ lastErrorAt: undefined,
233
+ consecutiveErrors: 0,
234
+ });
235
+
236
+ // Trigger immediate fetch
237
+ refreshFeedNow(application, feedId).catch((error) => {
238
+ console.error(
239
+ `[Microsub] Error refreshing updated feed ${newUrl}:`,
240
+ error.message,
241
+ );
242
+ });
243
+
244
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
245
+ }
246
+
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Reader controller - barrel re-export
3
+ * @module controllers/reader
4
+ */
5
+
6
+ export {
7
+ index,
8
+ channels,
9
+ newChannel,
10
+ createChannelAction,
11
+ channel,
12
+ channelHtml,
13
+ settings,
14
+ updateSettings,
15
+ deleteChannelAction,
16
+ } from "./channel.js";
17
+
18
+ export {
19
+ feeds,
20
+ addFeed,
21
+ removeFeed,
22
+ feedDetails,
23
+ editFeedForm,
24
+ updateFeedUrl,
25
+ rediscoverFeed,
26
+ refreshFeed,
27
+ } from "./feed.js";
28
+
29
+ export {
30
+ timeline,
31
+ timelineHtml,
32
+ markAllRead,
33
+ markViewRead,
34
+ item,
35
+ } from "./timeline.js";
36
+
37
+ export { compose, submitCompose } from "./compose.js";
38
+
39
+ export { searchPage, searchFeeds, subscribe } from "./search.js";
40
+
41
+ export {
42
+ actorProfile,
43
+ followActorAction,
44
+ unfollowActorAction,
45
+ } from "./actor.js";
46
+
47
+ export { deck, deckSettings, saveDeckSettings } from "./deck.js";
48
+
49
+ import {
50
+ index,
51
+ channels,
52
+ newChannel,
53
+ createChannelAction,
54
+ channel,
55
+ channelHtml,
56
+ settings,
57
+ updateSettings,
58
+ deleteChannelAction,
59
+ } from "./channel.js";
60
+
61
+ import {
62
+ feeds,
63
+ addFeed,
64
+ removeFeed,
65
+ feedDetails,
66
+ editFeedForm,
67
+ updateFeedUrl,
68
+ rediscoverFeed,
69
+ refreshFeed,
70
+ } from "./feed.js";
71
+
72
+ import {
73
+ timeline,
74
+ timelineHtml,
75
+ markAllRead,
76
+ markViewRead,
77
+ item,
78
+ } from "./timeline.js";
79
+
80
+ import { compose, submitCompose } from "./compose.js";
81
+
82
+ import { searchPage, searchFeeds, subscribe } from "./search.js";
83
+
84
+ import {
85
+ actorProfile,
86
+ followActorAction,
87
+ unfollowActorAction,
88
+ } from "./actor.js";
89
+
90
+ import { deck, deckSettings, saveDeckSettings } from "./deck.js";
91
+
92
+ export const readerController = {
93
+ index,
94
+ channels,
95
+ newChannel,
96
+ createChannel: createChannelAction,
97
+ channel,
98
+ channelHtml,
99
+ settings,
100
+ updateSettings,
101
+ markAllRead,
102
+ markViewRead,
103
+ deleteChannel: deleteChannelAction,
104
+ feeds,
105
+ addFeed,
106
+ removeFeed,
107
+ feedDetails,
108
+ editFeedForm,
109
+ updateFeedUrl,
110
+ rediscoverFeed,
111
+ refreshFeed,
112
+ item,
113
+ compose,
114
+ submitCompose,
115
+ searchPage,
116
+ searchFeeds,
117
+ subscribe,
118
+ actorProfile,
119
+ followActorAction,
120
+ unfollowActorAction,
121
+ timeline,
122
+ timelineHtml,
123
+ deck,
124
+ deckSettings,
125
+ saveDeckSettings,
126
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Feed discovery UI
3
+ * @module controllers/reader/search
4
+ */
5
+
6
+ import { discoverAndValidateFeeds } from "../../feeds/discovery.js";
7
+ import { validateFeedUrl } from "../../feeds/validator.js";
8
+ import { refreshFeedNow } from "../../polling/scheduler.js";
9
+ import { getChannels, getChannel } from "../../storage/channels.js";
10
+ import { createFeed } from "../../storage/feeds.js";
11
+ import { getUserId } from "../../utils/auth.js";
12
+
13
+ /**
14
+ * Search/discover feeds page
15
+ * @param {object} request - Express request
16
+ * @param {object} response - Express response
17
+ * @returns {Promise<void>}
18
+ */
19
+ export async function searchPage(request, response) {
20
+ const { application } = request.app.locals;
21
+ const userId = getUserId(request);
22
+
23
+ const channelList = await getChannels(application, userId);
24
+
25
+ response.render("search", {
26
+ title: request.__("microsub.search.title"),
27
+ channels: channelList,
28
+ baseUrl: request.baseUrl,
29
+ readerBaseUrl: request.baseUrl,
30
+ activeView: "channels",
31
+ breadcrumbs: [
32
+ { text: "Reader", href: request.baseUrl },
33
+ { text: "Search" },
34
+ ],
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Search for feeds from URL - enhanced with validation
40
+ * @param {object} request - Express request
41
+ * @param {object} response - Express response
42
+ * @returns {Promise<void>}
43
+ */
44
+ export async function searchFeeds(request, response) {
45
+ const { application } = request.app.locals;
46
+ const userId = getUserId(request);
47
+ const { query } = request.body;
48
+
49
+ const channelList = await getChannels(application, userId);
50
+
51
+ let results = [];
52
+ let discoveryError = null;
53
+
54
+ if (query) {
55
+ try {
56
+ // Use enhanced discovery with validation
57
+ results = await discoverAndValidateFeeds(query);
58
+ } catch (error) {
59
+ discoveryError = error.message;
60
+ }
61
+ }
62
+
63
+ response.render("search", {
64
+ title: request.__("microsub.search.title"),
65
+ channels: channelList,
66
+ query,
67
+ results,
68
+ discoveryError,
69
+ searched: true,
70
+ baseUrl: request.baseUrl,
71
+ readerBaseUrl: request.baseUrl,
72
+ activeView: "channels",
73
+ breadcrumbs: [
74
+ { text: "Reader", href: request.baseUrl },
75
+ { text: "Search" },
76
+ ],
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Subscribe to a feed from search results - with validation
82
+ * @param {object} request - Express request
83
+ * @param {object} response - Express response
84
+ * @returns {Promise<void>}
85
+ */
86
+ export async function subscribe(request, response) {
87
+ const { application } = request.app.locals;
88
+ const userId = getUserId(request);
89
+ const { url, channel: channelUid, skipValidation } = request.body;
90
+
91
+ const channelDocument = await getChannel(application, channelUid, userId);
92
+ if (!channelDocument) {
93
+ return response.status(404).render("404");
94
+ }
95
+
96
+ // Validate feed unless explicitly skipped (for power users)
97
+ if (!skipValidation) {
98
+ const validation = await validateFeedUrl(url);
99
+
100
+ if (!validation.valid) {
101
+ const channelList = await getChannels(application, userId);
102
+ return response.render("search", {
103
+ title: request.__("microsub.search.title"),
104
+ channels: channelList,
105
+ query: url,
106
+ validationError: validation.error,
107
+ baseUrl: request.baseUrl,
108
+ readerBaseUrl: request.baseUrl,
109
+ activeView: "channels",
110
+ breadcrumbs: [
111
+ { text: "Reader", href: request.baseUrl },
112
+ { text: "Search" },
113
+ ],
114
+ });
115
+ }
116
+
117
+ // Warn about comments feeds but allow subscription
118
+ if (validation.isCommentsFeed) {
119
+ console.warn(`[Microsub] Subscribing to comments feed: ${url}`);
120
+ }
121
+ }
122
+
123
+ // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
124
+ try {
125
+ const feed = await createFeed(application, {
126
+ channelId: channelDocument._id,
127
+ url,
128
+ title: undefined,
129
+ photo: undefined,
130
+ });
131
+
132
+ // Trigger immediate fetch in background
133
+ refreshFeedNow(application, feed._id).catch((error) => {
134
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
135
+ });
136
+
137
+ response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
138
+ } catch (error) {
139
+ if (error.code === "DUPLICATE_FEED") {
140
+ const channelList = await getChannels(application, userId);
141
+ return response.render("search", {
142
+ title: request.__("microsub.search.title"),
143
+ channels: channelList,
144
+ query: url,
145
+ validationError: `This feed already exists in channel "${error.channelName}"`,
146
+ baseUrl: request.baseUrl,
147
+ readerBaseUrl: request.baseUrl,
148
+ activeView: "channels",
149
+ breadcrumbs: [
150
+ { text: "Reader", href: request.baseUrl },
151
+ { text: "Search" },
152
+ ],
153
+ });
154
+ }
155
+ throw error;
156
+ }
157
+ }