@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,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
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Timeline views + read state
3
+ * @module controllers/reader/timeline
4
+ */
5
+
6
+ import { getChannel, getChannelById, getChannelsWithColors } from "../../storage/channels.js";
7
+ import {
8
+ getTimelineItems,
9
+ getAllTimelineItems,
10
+ getItemById,
11
+ } from "../../storage/items.js";
12
+ import { markItemsRead } from "../../storage/items-read-state.js";
13
+ import { getUserId } from "../../utils/auth.js";
14
+ import { proxyItemImages } from "../../media/proxy.js";
15
+
16
+ /**
17
+ * Timeline view - all channels chronologically
18
+ * @param {object} request - Express request
19
+ * @param {object} response - Express response
20
+ */
21
+ export async function timeline(request, response) {
22
+ const { application } = request.app.locals;
23
+ const userId = getUserId(request);
24
+ const { before, after } = request.query;
25
+
26
+ // Get channels with colors for filtering UI and item decoration
27
+ const channelList = await getChannelsWithColors(application, userId);
28
+
29
+ // Build channel lookup map (ObjectId string -> { name, color, uid })
30
+ const channelMap = new Map();
31
+ for (const ch of channelList) {
32
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
33
+ }
34
+
35
+ // Parse excluded channel IDs from query params
36
+ const excludeParam = request.query.exclude;
37
+ const excludeIds = excludeParam
38
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
39
+ : [];
40
+
41
+ // Exclude the notifications channel by default
42
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
43
+ const excludeChannelIds = [...excludeIds];
44
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
45
+ excludeChannelIds.push(notificationsChannel._id.toString());
46
+ }
47
+
48
+ const result = await getAllTimelineItems(application, {
49
+ before,
50
+ after,
51
+ userId,
52
+ excludeChannelIds,
53
+ });
54
+
55
+ // Proxy images
56
+ const proxyBaseUrl = application.url;
57
+ if (proxyBaseUrl && result.items) {
58
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
59
+ }
60
+
61
+ // Decorate items with channel name and color
62
+ for (const item of result.items) {
63
+ if (item._channelId) {
64
+ const info = channelMap.get(item._channelId);
65
+ if (info) {
66
+ item._channelName = info.name;
67
+ item._channelColor = info.color;
68
+ item._channelUid = info.uid;
69
+ }
70
+ }
71
+ }
72
+
73
+ // Set view preference cookie
74
+ if (request.session) request.session.microsubView = "timeline";
75
+
76
+ response.render("timeline", {
77
+ title: "Timeline",
78
+ channels: channelList,
79
+ items: result.items,
80
+ paging: result.paging,
81
+ excludeIds,
82
+ baseUrl: request.baseUrl,
83
+ readerBaseUrl: request.baseUrl,
84
+ activeView: "timeline",
85
+ breadcrumbs: [
86
+ { text: "Reader", href: request.baseUrl },
87
+ { text: "Timeline" },
88
+ ],
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Return rendered HTML fragments for timeline infinite scroll
94
+ * @param {object} request - Express request
95
+ * @param {object} response - Express response
96
+ * @returns {Promise<void>}
97
+ */
98
+ export async function timelineHtml(request, response) {
99
+ const { application } = request.app.locals;
100
+ const userId = getUserId(request);
101
+ const { before, after } = request.query;
102
+
103
+ const channelList = await getChannelsWithColors(application, userId);
104
+ const channelMap = new Map();
105
+ for (const ch of channelList) {
106
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
107
+ }
108
+
109
+ const excludeParam = request.query.exclude;
110
+ const excludeIds = excludeParam
111
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
112
+ : [];
113
+
114
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
115
+ const excludeChannelIds = [...excludeIds];
116
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
117
+ excludeChannelIds.push(notificationsChannel._id.toString());
118
+ }
119
+
120
+ const result = await getAllTimelineItems(application, {
121
+ before,
122
+ after,
123
+ userId,
124
+ excludeChannelIds,
125
+ });
126
+
127
+ const proxyBaseUrl = application.url;
128
+ if (proxyBaseUrl && result.items) {
129
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
130
+ }
131
+
132
+ for (const item of result.items) {
133
+ if (item._channelId) {
134
+ const info = channelMap.get(item._channelId);
135
+ if (info) {
136
+ item._channelName = info.name;
137
+ item._channelColor = info.color;
138
+ item._channelUid = info.uid;
139
+ }
140
+ }
141
+ }
142
+
143
+ const fragmentHtml = await new Promise((resolve, reject) => {
144
+ response.render("partials/items-fragment-timeline", {
145
+ items: result.items,
146
+ baseUrl: request.baseUrl,
147
+ }, (error, html) => error ? reject(error) : resolve(html));
148
+ });
149
+
150
+ response.json({
151
+ html: fragmentHtml,
152
+ paging: result.paging,
153
+ count: result.items.length,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Mark all items in channel as read
159
+ * @param {object} request - Express request
160
+ * @param {object} response - Express response
161
+ * @returns {Promise<void>}
162
+ */
163
+ export async function markAllRead(request, response) {
164
+ const { application } = request.app.locals;
165
+ const userId = getUserId(request);
166
+ const { channel: channelUid } = request.body;
167
+
168
+ const channelDocument = await getChannel(application, channelUid, userId);
169
+ if (!channelDocument) {
170
+ return response.status(404).render("404");
171
+ }
172
+
173
+ // Mark all items as read using the special "last-read-entry" value
174
+ await markItemsRead(
175
+ application,
176
+ channelDocument._id,
177
+ ["last-read-entry"],
178
+ userId,
179
+ );
180
+
181
+ response.redirect(`${request.baseUrl}/channels/${channelUid}`);
182
+ }
183
+
184
+ /**
185
+ * Mark specific items as read (no-JS form fallback for mark-view-as-read)
186
+ * @param {object} request - Express request
187
+ * @param {object} response - Express response
188
+ */
189
+ export async function markViewRead(request, response) {
190
+ const { application } = request.app.locals;
191
+ const userId = getUserId(request);
192
+ const { channel: channelUid } = request.body;
193
+ let { entry } = request.body;
194
+
195
+ const channelDocument = await getChannel(application, channelUid, userId);
196
+ if (!channelDocument) {
197
+ return response.status(404).render("404");
198
+ }
199
+
200
+ const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
201
+ if (entryIds.length > 0) {
202
+ await markItemsRead(application, channelDocument._id, entryIds, userId);
203
+ }
204
+
205
+ response.redirect(`${request.baseUrl}/channels/${channelUid}`);
206
+ }
207
+
208
+ /**
209
+ * View single item
210
+ * @param {object} request - Express request
211
+ * @param {object} response - Express response
212
+ * @returns {Promise<void>}
213
+ */
214
+ export async function item(request, response) {
215
+ const { application } = request.app.locals;
216
+ const userId = getUserId(request);
217
+ const { id } = request.params;
218
+
219
+ const itemDocument = await getItemById(application, id, userId);
220
+ if (!itemDocument) {
221
+ return response.status(404).render("404");
222
+ }
223
+
224
+ // Get the channel for this item (needed for mark-read)
225
+ // Note: transformToJf2() renames channelId → _channelId (string)
226
+ let channel = null;
227
+ if (itemDocument._channelId) {
228
+ channel = await getChannelById(application, itemDocument._channelId);
229
+ }
230
+
231
+ const itemBreadcrumbs = [
232
+ { text: "Reader", href: request.baseUrl },
233
+ ];
234
+ if (channel) {
235
+ itemBreadcrumbs.push(
236
+ { text: "Channels", href: `${request.baseUrl}/channels` },
237
+ { text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
238
+ );
239
+ }
240
+ itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
241
+
242
+ response.render("item", {
243
+ title: itemDocument.name || "Item",
244
+ item: itemDocument,
245
+ channel,
246
+ baseUrl: request.baseUrl,
247
+ readerBaseUrl: request.baseUrl,
248
+ activeView: "channels",
249
+ breadcrumbs: itemBreadcrumbs,
250
+ });
251
+ }
@@ -9,11 +9,13 @@ import { proxyItemImages } from "../media/proxy.js";
9
9
  import { getChannel, getChannelById } from "../storage/channels.js";
10
10
  import {
11
11
  getTimelineItems,
12
+ removeItems,
13
+ } from "../storage/items.js";
14
+ import {
12
15
  markFeedItemsRead,
13
16
  markItemsRead,
14
17
  markItemsUnread,
15
- removeItems,
16
- } from "../storage/items.js";
18
+ } from "../storage/items-read-state.js";
17
19
  import { getUserId } from "../utils/auth.js";
18
20
  import {
19
21
  validateChannel,
package/lib/feeds/atom.js CHANGED
@@ -7,7 +7,7 @@ import { Readable } from "node:stream";
7
7
 
8
8
  import FeedParser from "feedparser";
9
9
 
10
- import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
10
+ import { normalizeItem, normalizeFeedMeta } from "./normalizer-rss.js";
11
11
 
12
12
  /**
13
13
  * Parse Atom feed content
@@ -3,7 +3,6 @@
3
3
  * @module feeds/fetcher
4
4
  */
5
5
 
6
- import { getCache, setCache } from "../cache/redis.js";
7
6
  import { isPrivateUrl } from "../media/proxy.js";
8
7
 
9
8
  const DEFAULT_TIMEOUT = 30_000; // 30 seconds
@@ -16,32 +15,16 @@ const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
16
15
  * @param {string} [options.etag] - Previous ETag for conditional request
17
16
  * @param {string} [options.lastModified] - Previous Last-Modified for conditional request
18
17
  * @param {number} [options.timeout] - Request timeout in ms
19
- * @param {object} [options.redis] - Redis client for caching
20
18
  * @returns {Promise<object>} Fetch result with content and headers
21
19
  */
22
20
  export async function fetchFeed(url, options = {}) {
23
- const { etag, lastModified, timeout = DEFAULT_TIMEOUT, redis } = options;
21
+ const { etag, lastModified, timeout = DEFAULT_TIMEOUT } = options;
24
22
 
25
23
  // SSRF protection — block private/internal IPs (including DNS rebinding)
26
24
  if (await isPrivateUrl(url)) {
27
25
  throw new Error(`Feed URL blocked (private/internal address): ${url}`);
28
26
  }
29
27
 
30
- // Check cache first
31
- if (redis) {
32
- const cached = await getCache(redis, `feed:${url}`);
33
- if (cached) {
34
- return {
35
- content: cached.content,
36
- contentType: cached.contentType,
37
- etag: cached.etag,
38
- lastModified: cached.lastModified,
39
- fromCache: true,
40
- status: 200,
41
- };
42
- }
43
- }
44
-
45
28
  const headers = {
46
29
  Accept:
47
30
  "application/atom+xml, application/rss+xml, application/json, application/feed+json, text/xml, text/html;q=0.9, */*;q=0.8",
@@ -105,18 +88,6 @@ export async function fetchFeed(url, options = {}) {
105
88
  result.self = extractSelfFromLinkHeader(linkHeader);
106
89
  }
107
90
 
108
- // Cache the result
109
- if (redis) {
110
- const cacheData = {
111
- content,
112
- contentType,
113
- etag: responseEtag,
114
- lastModified: responseLastModified,
115
- };
116
- // Cache for 5 minutes by default
117
- await setCache(redis, `feed:${url}`, cacheData, 300);
118
- }
119
-
120
91
  return result;
121
92
  } catch (error) {
122
93
  clearTimeout(timeoutId);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { mf2 } from "microformats-parser";
7
7
 
8
- import { normalizeHfeedItem, normalizeHfeedMeta } from "./normalizer.js";
8
+ import { normalizeHfeedItem, normalizeHfeedMeta } from "./normalizer-hfeed.js";
9
9
 
10
10
  /**
11
11
  * Parse h-feed content from HTML
@@ -3,7 +3,7 @@
3
3
  * @module feeds/jsonfeed
4
4
  */
5
5
 
6
- import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer.js";
6
+ import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer-jsonfeed.js";
7
7
 
8
8
  /**
9
9
  * Parse JSON Feed content