@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,242 @@
1
+ /**
2
+ * Micropub compose
3
+ * @module controllers/reader/compose
4
+ */
5
+
6
+ import { classifyUrl } from "../../utils/source-type.js";
7
+
8
+ /**
9
+ * Ensure value is a string URL
10
+ * @param {string|object|undefined} value - Value to check
11
+ * @returns {string|undefined} String value or undefined
12
+ */
13
+ function ensureString(value) {
14
+ if (!value) return;
15
+ if (typeof value === "string") return value;
16
+ if (typeof value === "object" && value.url) return value.url;
17
+ return String(value);
18
+ }
19
+
20
+ /**
21
+ * Fetch syndication targets from Micropub config
22
+ * @param {object} application - Indiekit application
23
+ * @param {string} token - Auth token
24
+ * @returns {Promise<Array>} Syndication targets
25
+ */
26
+ async function getSyndicationTargets(application, token) {
27
+ try {
28
+ const micropubEndpoint = application.micropubEndpoint;
29
+ if (!micropubEndpoint) return [];
30
+
31
+ const micropubUrl = micropubEndpoint.startsWith("http")
32
+ ? micropubEndpoint
33
+ : new URL(micropubEndpoint, application.url).href;
34
+
35
+ const configUrl = `${micropubUrl}?q=config`;
36
+ const configResponse = await fetch(configUrl, {
37
+ headers: {
38
+ Authorization: `Bearer ${token}`,
39
+ Accept: "application/json",
40
+ },
41
+ });
42
+
43
+ if (!configResponse.ok) return [];
44
+
45
+ const config = await configResponse.json();
46
+ return config["syndicate-to"] || [];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Compose response form
54
+ * @param {object} request - Express request
55
+ * @param {object} response - Express response
56
+ * @returns {Promise<void>}
57
+ */
58
+ export async function compose(request, response) {
59
+ const { application } = request.app.locals;
60
+
61
+ // Support both long-form (replyTo) and short-form (reply) query params
62
+ const {
63
+ replyTo,
64
+ reply,
65
+ likeOf,
66
+ like,
67
+ repostOf,
68
+ repost,
69
+ bookmarkOf,
70
+ bookmark,
71
+ } = request.query;
72
+
73
+ // Fetch syndication targets if user is authenticated
74
+ const token = request.session?.access_token;
75
+ const syndicationTargets = token
76
+ ? await getSyndicationTargets(application, token)
77
+ : [];
78
+
79
+ // Auto-select syndication target based on interaction URL protocol
80
+ const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
81
+ if (interactionUrl && syndicationTargets.length > 0) {
82
+ const protocol = classifyUrl(interactionUrl).protocol;
83
+ for (const target of syndicationTargets) {
84
+ const targetId = (target.uid || target.name || "").toLowerCase();
85
+ if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
86
+ target.checked = true;
87
+ } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
88
+ target.checked = true;
89
+ }
90
+ }
91
+ }
92
+
93
+ response.render("compose", {
94
+ title: request.__("microsub.compose.title"),
95
+ replyTo: ensureString(replyTo || reply),
96
+ likeOf: ensureString(likeOf || like),
97
+ repostOf: ensureString(repostOf || repost),
98
+ bookmarkOf: ensureString(bookmarkOf || bookmark),
99
+ syndicationTargets,
100
+ baseUrl: request.baseUrl,
101
+ readerBaseUrl: request.baseUrl,
102
+ activeView: "channels",
103
+ breadcrumbs: [
104
+ { text: "Reader", href: request.baseUrl },
105
+ { text: "Compose" },
106
+ ],
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Submit composed response via Micropub
112
+ * @param {object} request - Express request
113
+ * @param {object} response - Express response
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function submitCompose(request, response) {
117
+ const { application } = request.app.locals;
118
+ const { content } = request.body;
119
+ const inReplyTo = request.body["in-reply-to"];
120
+ const likeOf = request.body["like-of"];
121
+ const repostOf = request.body["repost-of"];
122
+ const bookmarkOf = request.body["bookmark-of"];
123
+ const syndicateTo = request.body["mp-syndicate-to"];
124
+
125
+ // Get Micropub endpoint
126
+ const micropubEndpoint = application.micropubEndpoint;
127
+ if (!micropubEndpoint) {
128
+ return response.status(500).render("error", {
129
+ title: "Error",
130
+ content: "Micropub endpoint not configured",
131
+ });
132
+ }
133
+
134
+ // Build absolute Micropub URL
135
+ const micropubUrl = micropubEndpoint.startsWith("http")
136
+ ? micropubEndpoint
137
+ : new URL(micropubEndpoint, application.url).href;
138
+
139
+ // Get auth token from session
140
+ const token = request.session?.access_token;
141
+ if (!token) {
142
+ return response.redirect("/session/login?redirect=" + request.originalUrl);
143
+ }
144
+
145
+ // Build Micropub request body
146
+ const micropubData = new URLSearchParams();
147
+ micropubData.append("h", "entry");
148
+
149
+ if (likeOf) {
150
+ // Like post - content is optional comment
151
+ micropubData.append("like-of", likeOf);
152
+ if (content && content.trim()) {
153
+ micropubData.append("content", content.trim());
154
+ }
155
+ } else if (repostOf) {
156
+ // Repost - content is optional comment
157
+ micropubData.append("repost-of", repostOf);
158
+ if (content && content.trim()) {
159
+ micropubData.append("content", content.trim());
160
+ }
161
+ } else if (bookmarkOf) {
162
+ // Bookmark - content is optional comment
163
+ micropubData.append("bookmark-of", bookmarkOf);
164
+ if (content && content.trim()) {
165
+ micropubData.append("content", content.trim());
166
+ }
167
+ } else if (inReplyTo) {
168
+ // Reply
169
+ micropubData.append("in-reply-to", inReplyTo);
170
+ micropubData.append("content", content || "");
171
+ } else {
172
+ // Regular note
173
+ micropubData.append("content", content || "");
174
+ }
175
+
176
+ // Add syndication targets
177
+ if (syndicateTo) {
178
+ const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];
179
+ for (const target of targets) {
180
+ micropubData.append("mp-syndicate-to", target);
181
+ }
182
+ }
183
+
184
+ try {
185
+ const micropubResponse = await fetch(micropubUrl, {
186
+ method: "POST",
187
+ headers: {
188
+ Authorization: `Bearer ${token}`,
189
+ "Content-Type": "application/x-www-form-urlencoded",
190
+ Accept: "application/json",
191
+ },
192
+ body: micropubData.toString(),
193
+ });
194
+
195
+ if (
196
+ micropubResponse.ok ||
197
+ micropubResponse.status === 201 ||
198
+ micropubResponse.status === 202
199
+ ) {
200
+ // Success - get the Location header for the new post URL
201
+ const location = micropubResponse.headers.get("Location");
202
+ console.info(
203
+ `[Microsub] Created post via Micropub: ${location || "success"}`,
204
+ );
205
+
206
+ // Redirect back to reader with success message
207
+ return response.redirect(`${request.baseUrl}/channels`);
208
+ }
209
+
210
+ // Handle error
211
+ const errorBody = await micropubResponse.text();
212
+ const statusText = micropubResponse.statusText || "Unknown error";
213
+ console.error(
214
+ `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
215
+ );
216
+
217
+ // Parse error message from response body if JSON
218
+ let errorMessage = `Micropub error: ${statusText}`;
219
+ try {
220
+ const errorJson = JSON.parse(errorBody);
221
+ if (errorJson.error_description) {
222
+ errorMessage = String(errorJson.error_description);
223
+ } else if (errorJson.error) {
224
+ errorMessage = String(errorJson.error);
225
+ }
226
+ } catch {
227
+ // Not JSON, use status text
228
+ }
229
+
230
+ return response.status(micropubResponse.status).render("error", {
231
+ title: "Error",
232
+ content: errorMessage,
233
+ });
234
+ } catch (error) {
235
+ console.error(`[Microsub] Micropub request failed: ${error.message}`);
236
+
237
+ return response.status(500).render("error", {
238
+ title: "Error",
239
+ content: `Failed to create post: ${error.message}`,
240
+ });
241
+ }
242
+ }
@@ -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
+ }