@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.58

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 (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +251 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Deck view
3
+ * @module controllers/reader/deck
4
+ */
5
+
6
+ import { getChannelsWithColors } from "../../storage/channels.js";
7
+ import { getTimelineItems } from "../../storage/items.js";
8
+ import { getDeckConfig, saveDeckConfig } from "../../storage/deck.js";
9
+ import { getUserId } from "../../utils/auth.js";
10
+ import { proxyItemImages } from "../../media/proxy.js";
11
+
12
+ /**
13
+ * Deck view - TweetDeck-style columns
14
+ * @param {object} request - Express request
15
+ * @param {object} response - Express response
16
+ */
17
+ export async function deck(request, response) {
18
+ const { application } = request.app.locals;
19
+ const userId = getUserId(request);
20
+
21
+ const channelList = await getChannelsWithColors(application, userId);
22
+ const deckConfig = await getDeckConfig(application, userId);
23
+
24
+ // Determine which channels to show as columns
25
+ let columnChannels;
26
+ if (deckConfig?.columns?.length > 0) {
27
+ // Use saved config order
28
+ const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
29
+ columnChannels = deckConfig.columns
30
+ .map((col) => channelMap.get(col.channelId.toString()))
31
+ .filter(Boolean);
32
+ } else {
33
+ // Default: all channels except notifications
34
+ columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
35
+ }
36
+
37
+ // Fetch items for each column (limited to 10 per column for performance)
38
+ // Batch in groups of 4 to avoid overwhelming MongoDB with parallel queries
39
+ const proxyBaseUrl = application.url;
40
+ const columns = [];
41
+ for (let i = 0; i < columnChannels.length; i += 4) {
42
+ const batch = columnChannels.slice(i, i + 4);
43
+ const batchResults = await Promise.all(
44
+ batch.map(async (channel) => {
45
+ const result = await getTimelineItems(application, channel._id, {
46
+ userId,
47
+ limit: 10,
48
+ });
49
+
50
+ if (proxyBaseUrl && result.items) {
51
+ result.items = result.items.map((item) =>
52
+ proxyItemImages(item, proxyBaseUrl),
53
+ );
54
+ }
55
+
56
+ return {
57
+ channel,
58
+ items: result.items,
59
+ paging: result.paging,
60
+ };
61
+ }),
62
+ );
63
+ columns.push(...batchResults);
64
+ }
65
+
66
+ // Set view preference cookie
67
+ if (request.session) request.session.microsubView = "deck";
68
+
69
+ response.render("deck", {
70
+ title: "Deck",
71
+ columns,
72
+ baseUrl: request.baseUrl,
73
+ readerBaseUrl: request.baseUrl,
74
+ activeView: "deck",
75
+ breadcrumbs: [
76
+ { text: "Reader", href: request.baseUrl },
77
+ { text: "Deck" },
78
+ ],
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Deck settings page
84
+ * @param {object} request - Express request
85
+ * @param {object} response - Express response
86
+ */
87
+ export async function deckSettings(request, response) {
88
+ const { application } = request.app.locals;
89
+ const userId = getUserId(request);
90
+
91
+ const channelList = await getChannelsWithColors(application, userId);
92
+ const deckConfig = await getDeckConfig(application, userId);
93
+
94
+ const selectedIds = deckConfig?.columns
95
+ ? deckConfig.columns.map((col) => col.channelId.toString())
96
+ : channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
97
+
98
+ response.render("deck-settings", {
99
+ title: "Deck settings",
100
+ channels: channelList,
101
+ selectedIds,
102
+ baseUrl: request.baseUrl,
103
+ readerBaseUrl: request.baseUrl,
104
+ activeView: "deck",
105
+ breadcrumbs: [
106
+ { text: "Reader", href: request.baseUrl },
107
+ { text: "Deck", href: `${request.baseUrl}/deck` },
108
+ { text: "Settings" },
109
+ ],
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Save deck settings
115
+ * @param {object} request - Express request
116
+ * @param {object} response - Express response
117
+ */
118
+ export async function saveDeckSettings(request, response) {
119
+ const { application } = request.app.locals;
120
+ const userId = getUserId(request);
121
+
122
+ let { columns } = request.body;
123
+ if (!columns) columns = [];
124
+ if (!Array.isArray(columns)) columns = [columns];
125
+
126
+ await saveDeckConfig(application, userId, columns);
127
+
128
+ response.redirect(`${request.baseUrl}/deck`);
129
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Feed repair operations (rediscover + force refresh)
3
+ * @module controllers/reader/feed-repair
4
+ */
5
+
6
+ import { discoverAndValidateFeeds, getBestFeed } from "../../feeds/discovery.js";
7
+ import { refreshFeedNow } from "../../polling/scheduler.js";
8
+ import { getChannel } from "../../storage/channels.js";
9
+ import { getFeedById, updateFeed, updateFeedStatus } from "../../storage/feeds.js";
10
+ import { getUserId } from "../../utils/auth.js";
11
+
12
+ /**
13
+ * Rediscover feed - run discovery on URL to find actual RSS feed
14
+ * @param {object} request - Express request
15
+ * @param {object} response - Express response
16
+ * @returns {Promise<void>}
17
+ */
18
+ export async function rediscoverFeed(request, response) {
19
+ const { application } = request.app.locals;
20
+ const userId = getUserId(request);
21
+ const { uid, feedId } = request.params;
22
+
23
+ const channelDocument = await getChannel(application, uid, userId);
24
+ if (!channelDocument) {
25
+ return response.status(404).render("404");
26
+ }
27
+
28
+ const feed = await getFeedById(application, feedId);
29
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
30
+ return response.status(404).render("404");
31
+ }
32
+
33
+ // Run feed discovery on the current URL
34
+ try {
35
+ const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
36
+ const bestFeed = getBestFeed(discoveredFeeds);
37
+
38
+ if (bestFeed && bestFeed.url !== feed.url) {
39
+ // Found a different (better) feed URL - update the record
40
+ await updateFeed(application, feedId, {
41
+ url: bestFeed.url,
42
+ title: bestFeed.title || feed.title,
43
+ status: "active",
44
+ lastError: undefined,
45
+ lastErrorAt: undefined,
46
+ consecutiveErrors: 0,
47
+ });
48
+
49
+ console.info(
50
+ `[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
51
+ );
52
+
53
+ // Trigger immediate fetch
54
+ refreshFeedNow(application, feedId).catch((error) => {
55
+ console.error(
56
+ `[Microsub] Error refreshing rediscovered feed:`,
57
+ error.message,
58
+ );
59
+ });
60
+ } else if (bestFeed) {
61
+ // Same URL but valid - just reset error state and refresh
62
+ await updateFeedStatus(application, feedId, { success: true });
63
+ await updateFeed(application, feedId, {
64
+ status: "active",
65
+ lastError: undefined,
66
+ lastErrorAt: undefined,
67
+ consecutiveErrors: 0,
68
+ });
69
+
70
+ refreshFeedNow(application, feedId).catch((error) => {
71
+ console.error(`[Microsub] Error refreshing feed:`, error.message);
72
+ });
73
+ } else {
74
+ // No valid feed found
75
+ await updateFeedStatus(application, feedId, {
76
+ success: false,
77
+ error: "No valid feed found at this URL",
78
+ });
79
+ }
80
+ } catch (error) {
81
+ await updateFeedStatus(application, feedId, {
82
+ success: false,
83
+ error: error.message,
84
+ });
85
+ }
86
+
87
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
88
+ }
89
+
90
+ /**
91
+ * Force refresh a feed
92
+ * @param {object} request - Express request
93
+ * @param {object} response - Express response
94
+ * @returns {Promise<void>}
95
+ */
96
+ export async function refreshFeed(request, response) {
97
+ const { application } = request.app.locals;
98
+ const userId = getUserId(request);
99
+ const { uid, feedId } = request.params;
100
+
101
+ const channelDocument = await getChannel(application, uid, userId);
102
+ if (!channelDocument) {
103
+ return response.status(404).render("404");
104
+ }
105
+
106
+ const feed = await getFeedById(application, feedId);
107
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
108
+ return response.status(404).render("404");
109
+ }
110
+
111
+ // Trigger immediate fetch
112
+ refreshFeedNow(application, feedId).catch((error) => {
113
+ console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
114
+ });
115
+
116
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
117
+ }
@@ -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
+ };