@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
@@ -3,8 +3,8 @@
3
3
  * @module cache/redis
4
4
  */
5
5
 
6
- import Redis from "ioredis";
7
-
6
+ // Redis is dynamically imported only when needed so the package is optional
7
+ let Redis;
8
8
  let redisClient;
9
9
 
10
10
  /**
@@ -12,7 +12,7 @@ let redisClient;
12
12
  * @param {object} application - Indiekit application
13
13
  * @returns {object|undefined} Redis client or undefined
14
14
  */
15
- export function getRedisClient(application) {
15
+ export async function getRedisClient(application) {
16
16
  // Check if Redis is already initialized on the application
17
17
  if (application.redis) {
18
18
  return application.redis;
@@ -27,6 +27,15 @@ export function getRedisClient(application) {
27
27
  const redisUrl = application.config?.application?.redisUrl;
28
28
  if (redisUrl) {
29
29
  try {
30
+ if (!Redis) {
31
+ try {
32
+ Redis = (await import("ioredis")).default;
33
+ } catch {
34
+ console.warn("[Microsub] ioredis not available");
35
+ return;
36
+ }
37
+ }
38
+
30
39
  redisClient = new Redis(redisUrl, {
31
40
  maxRetriesPerRequest: 3,
32
41
  retryStrategy(times) {
@@ -56,6 +65,15 @@ export function getRedisClient(application) {
56
65
  }
57
66
  }
58
67
 
68
+ /**
69
+ * Namespace cache keys to prevent cross-instance collisions
70
+ * @param {string} key - Raw cache key
71
+ * @returns {string} Namespaced key
72
+ */
73
+ function nsKey(key) {
74
+ return `microsub:${key}`;
75
+ }
76
+
59
77
  /**
60
78
  * Get value from cache
61
79
  * @param {object} redis - Redis client
@@ -68,7 +86,7 @@ export async function getCache(redis, key) {
68
86
  }
69
87
 
70
88
  try {
71
- const value = await redis.get(key);
89
+ const value = await redis.get(nsKey(key));
72
90
  if (value) {
73
91
  return JSON.parse(value);
74
92
  }
@@ -92,9 +110,10 @@ export async function setCache(redis, key, value, ttl = 300) {
92
110
 
93
111
  try {
94
112
  const serialized = JSON.stringify(value);
113
+ const nk = nsKey(key);
95
114
  await (ttl
96
- ? redis.set(key, serialized, "EX", ttl)
97
- : redis.set(key, serialized));
115
+ ? redis.set(nk, serialized, "EX", ttl)
116
+ : redis.set(nk, serialized));
98
117
  } catch {
99
118
  // Ignore cache errors
100
119
  }
@@ -112,7 +131,7 @@ export async function deleteCache(redis, key) {
112
131
  }
113
132
 
114
133
  try {
115
- await redis.del(key);
134
+ await redis.del(nsKey(key));
116
135
  } catch {
117
136
  // Ignore cache errors
118
137
  }
@@ -17,7 +17,7 @@ import { getUserId } from "../utils/auth.js";
17
17
  import {
18
18
  validateChannel,
19
19
  validateChannelName,
20
- parseArrayParameter as parseArrayParametereter,
20
+ parseArrayParameter,
21
21
  } from "../utils/validation.js";
22
22
 
23
23
  /**
@@ -62,7 +62,7 @@ export async function action(request, response) {
62
62
 
63
63
  // Reorder channels
64
64
  if (method === "order") {
65
- const channelUids = parseArrayParametereter(request.body, "channels");
65
+ const channelUids = parseArrayParameter(request.body, "channels");
66
66
  if (channelUids.length === 0) {
67
67
  throw new IndiekitError("Missing channels[] parameter", {
68
68
  status: 400,
@@ -0,0 +1,142 @@
1
+ /**
2
+ * ActivityPub actor profiles
3
+ * @module controllers/reader/actor
4
+ */
5
+
6
+ import { fetchActorOutbox } from "../../activitypub/outbox-fetcher.js";
7
+
8
+ const ACTOR_OUTBOX_LIMIT = 30;
9
+
10
+ /**
11
+ * Find the ActivityPub plugin instance from installed plugins.
12
+ * @param {object} request - Express request
13
+ * @returns {object|undefined} The AP plugin instance
14
+ */
15
+ function getApPlugin(request) {
16
+ const installedPlugins = request.app.locals.installedPlugins;
17
+ if (!installedPlugins) return undefined;
18
+ return [...installedPlugins].find(
19
+ (p) => p.name === "ActivityPub endpoint",
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Actor profile — fetch and display a remote AP actor's recent posts
25
+ * @param {object} request - Express request
26
+ * @param {object} response - Express response
27
+ */
28
+ export async function actorProfile(request, response) {
29
+ const actorUrl = request.query.url;
30
+ if (!actorUrl) {
31
+ return response.status(400).render("404");
32
+ }
33
+
34
+ // Check if we already follow this actor
35
+ const { application } = request.app.locals;
36
+ const apFollowing = application?.collections?.get("ap_following");
37
+ let isFollowing = false;
38
+ if (apFollowing) {
39
+ const existing = await apFollowing.findOne({ actorUrl });
40
+ isFollowing = !!existing;
41
+ }
42
+
43
+ // Check if AP plugin is available (for follow button visibility)
44
+ const apPlugin = getApPlugin(request);
45
+ const canFollow = !!apPlugin;
46
+
47
+ try {
48
+ const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
49
+
50
+ response.render("actor", {
51
+ title: actor.name || "Actor",
52
+ actor,
53
+ items,
54
+ actorUrl,
55
+ isFollowing,
56
+ canFollow,
57
+ baseUrl: request.baseUrl,
58
+ readerBaseUrl: request.baseUrl,
59
+ activeView: "channels",
60
+ breadcrumbs: [
61
+ { text: "Reader", href: request.baseUrl },
62
+ { text: actor.name || "Actor" },
63
+ ],
64
+ });
65
+ } catch (error) {
66
+ console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
67
+ response.render("actor", {
68
+ title: "Actor",
69
+ actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
70
+ items: [],
71
+ actorUrl,
72
+ isFollowing,
73
+ canFollow,
74
+ baseUrl: request.baseUrl,
75
+ readerBaseUrl: request.baseUrl,
76
+ activeView: "channels",
77
+ error: "Could not fetch this actor's profile. They may have restricted access.",
78
+ breadcrumbs: [
79
+ { text: "Reader", href: request.baseUrl },
80
+ { text: "Actor" },
81
+ ],
82
+ });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Follow an ActivityPub actor
88
+ * @param {object} request - Express request
89
+ * @param {object} response - Express response
90
+ */
91
+ export async function followActorAction(request, response) {
92
+ const { actorUrl, actorName } = request.body;
93
+ if (!actorUrl) {
94
+ return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
95
+ }
96
+
97
+ const apPlugin = getApPlugin(request);
98
+ if (!apPlugin) {
99
+ console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
100
+ return response.redirect(
101
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
102
+ );
103
+ }
104
+
105
+ const result = await apPlugin.followActor(actorUrl, { name: actorName });
106
+ if (!result.ok) {
107
+ console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
108
+ }
109
+
110
+ return response.redirect(
111
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Unfollow an ActivityPub actor
117
+ * @param {object} request - Express request
118
+ * @param {object} response - Express response
119
+ */
120
+ export async function unfollowActorAction(request, response) {
121
+ const { actorUrl } = request.body;
122
+ if (!actorUrl) {
123
+ return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
124
+ }
125
+
126
+ const apPlugin = getApPlugin(request);
127
+ if (!apPlugin) {
128
+ console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
129
+ return response.redirect(
130
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
131
+ );
132
+ }
133
+
134
+ const result = await apPlugin.unfollowActor(actorUrl);
135
+ if (!result.ok) {
136
+ console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
137
+ }
138
+
139
+ return response.redirect(
140
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
141
+ );
142
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Channel CRUD + HTML fragments
3
+ * @module controllers/reader/channel
4
+ */
5
+
6
+ import {
7
+ getChannels,
8
+ getChannel,
9
+ createChannel,
10
+ updateChannelSettings,
11
+ deleteChannel,
12
+ } from "../../storage/channels.js";
13
+ import { getFeedsForChannel } from "../../storage/feeds.js";
14
+ import { getTimelineItems } from "../../storage/items.js";
15
+ import { countReadItems } from "../../storage/items-read-state.js";
16
+ import { getUserId } from "../../utils/auth.js";
17
+ import {
18
+ validateChannelName,
19
+ validateExcludeTypes,
20
+ validateExcludeRegex,
21
+ } from "../../utils/validation.js";
22
+ import { proxyItemImages } from "../../media/proxy.js";
23
+
24
+ /**
25
+ * Reader index - redirect to channels
26
+ * @param {object} request - Express request
27
+ * @param {object} response - Express response
28
+ */
29
+ export async function index(request, response) {
30
+ const lastView = request.session?.microsubView || "timeline";
31
+ const validViews = ["channels", "deck", "timeline"];
32
+ const view = validViews.includes(lastView) ? lastView : "timeline";
33
+ response.redirect(`${request.baseUrl}/${view}`);
34
+ }
35
+
36
+ /**
37
+ * List channels
38
+ * @param {object} request - Express request
39
+ * @param {object} response - Express response
40
+ */
41
+ export async function channels(request, response) {
42
+ const { application } = request.app.locals;
43
+ const userId = getUserId(request);
44
+
45
+ const channelList = await getChannels(application, userId);
46
+
47
+ if (request.session) request.session.microsubView = "channels";
48
+
49
+ response.render("reader", {
50
+ title: request.__("microsub.views.channels"),
51
+ channels: channelList,
52
+ baseUrl: request.baseUrl,
53
+ readerBaseUrl: request.baseUrl,
54
+ activeView: "channels",
55
+ breadcrumbs: [
56
+ { text: "Reader", href: request.baseUrl },
57
+ { text: "Channels" },
58
+ ],
59
+ });
60
+ }
61
+
62
+ /**
63
+ * New channel form
64
+ * @param {object} request - Express request
65
+ * @param {object} response - Express response
66
+ */
67
+ export async function newChannel(request, response) {
68
+ response.render("channel-new", {
69
+ title: request.__("microsub.channels.new"),
70
+ baseUrl: request.baseUrl,
71
+ readerBaseUrl: request.baseUrl,
72
+ activeView: "channels",
73
+ breadcrumbs: [
74
+ { text: "Reader", href: request.baseUrl },
75
+ { text: "Channels", href: `${request.baseUrl}/channels` },
76
+ { text: request.__("microsub.channels.new") },
77
+ ],
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Create channel
83
+ * @param {object} request - Express request
84
+ * @param {object} response - Express response
85
+ */
86
+ export async function createChannelAction(request, response) {
87
+ const { application } = request.app.locals;
88
+ const userId = getUserId(request);
89
+ const { name } = request.body;
90
+
91
+ validateChannelName(name);
92
+
93
+ await createChannel(application, { name, userId });
94
+
95
+ response.redirect(`${request.baseUrl}/channels`);
96
+ }
97
+
98
+ /**
99
+ * View channel timeline
100
+ * @param {object} request - Express request
101
+ * @param {object} response - Express response
102
+ * @returns {Promise<void>}
103
+ */
104
+ export async function channel(request, response) {
105
+ const { application } = request.app.locals;
106
+ const userId = getUserId(request);
107
+ const { uid } = request.params;
108
+ const { before, after, showRead } = request.query;
109
+
110
+ const channelDocument = await getChannel(application, uid, userId);
111
+ if (!channelDocument) {
112
+ return response.status(404).render("404");
113
+ }
114
+
115
+ // Check if showing read items
116
+ const showReadItems = showRead === "true";
117
+
118
+ const timeline = await getTimelineItems(application, channelDocument._id, {
119
+ before,
120
+ after,
121
+ userId,
122
+ showRead: showReadItems,
123
+ });
124
+
125
+ // Proxy images through media endpoint for privacy
126
+ const proxyBaseUrl = application.url;
127
+ if (proxyBaseUrl && timeline.items) {
128
+ timeline.items = timeline.items.map((item) =>
129
+ proxyItemImages(item, proxyBaseUrl),
130
+ );
131
+ }
132
+
133
+ // Count read items to show "View read items" button
134
+ const readCount = await countReadItems(
135
+ application,
136
+ channelDocument._id,
137
+ userId,
138
+ );
139
+
140
+ response.render("channel", {
141
+ title: channelDocument.name,
142
+ channel: channelDocument,
143
+ items: timeline.items,
144
+ paging: timeline.paging,
145
+ readCount,
146
+ showRead: showReadItems,
147
+ baseUrl: request.baseUrl,
148
+ readerBaseUrl: request.baseUrl,
149
+ activeView: "channels",
150
+ breadcrumbs: [
151
+ { text: "Reader", href: request.baseUrl },
152
+ { text: "Channels", href: `${request.baseUrl}/channels` },
153
+ { text: channelDocument.name },
154
+ ],
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Return rendered HTML fragments for infinite scroll
160
+ * @param {object} request - Express request
161
+ * @param {object} response - Express response
162
+ * @returns {Promise<void>}
163
+ */
164
+ export async function channelHtml(request, response) {
165
+ const { application } = request.app.locals;
166
+ const userId = getUserId(request);
167
+ const { uid } = request.params;
168
+ const { before, after, showRead } = request.query;
169
+
170
+ const channelDocument = await getChannel(application, uid, userId);
171
+ if (!channelDocument) {
172
+ return response.status(404).json({ error: "Channel not found" });
173
+ }
174
+
175
+ const showReadItems = showRead === "true";
176
+
177
+ const timeline = await getTimelineItems(application, channelDocument._id, {
178
+ before,
179
+ after,
180
+ userId,
181
+ showRead: showReadItems,
182
+ });
183
+
184
+ // Proxy images
185
+ const proxyBaseUrl = application.url;
186
+ if (proxyBaseUrl && timeline.items) {
187
+ timeline.items = timeline.items.map((item) =>
188
+ proxyItemImages(item, proxyBaseUrl),
189
+ );
190
+ }
191
+
192
+ // Render items via layout-less fragment template (standard response.render
193
+ // with callback returns HTML string without sending a response)
194
+ const fragmentHtml = await new Promise((resolve, reject) => {
195
+ response.render("partials/items-fragment", {
196
+ items: timeline.items,
197
+ channel: channelDocument,
198
+ baseUrl: request.baseUrl,
199
+ }, (error, html) => error ? reject(error) : resolve(html));
200
+ });
201
+
202
+ response.json({
203
+ html: fragmentHtml,
204
+ paging: timeline.paging,
205
+ count: timeline.items.length,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Channel settings form
211
+ * @param {object} request - Express request
212
+ * @param {object} response - Express response
213
+ * @returns {Promise<void>}
214
+ */
215
+ export async function settings(request, response) {
216
+ const { application } = request.app.locals;
217
+ const userId = getUserId(request);
218
+ const { uid } = request.params;
219
+
220
+ const channelDocument = await getChannel(application, uid, userId);
221
+ if (!channelDocument) {
222
+ return response.status(404).render("404");
223
+ }
224
+
225
+ response.render("settings", {
226
+ title: request.__("microsub.settings.title", {
227
+ channel: channelDocument.name,
228
+ }),
229
+ channel: channelDocument,
230
+ baseUrl: request.baseUrl,
231
+ readerBaseUrl: request.baseUrl,
232
+ activeView: "channels",
233
+ breadcrumbs: [
234
+ { text: "Reader", href: request.baseUrl },
235
+ { text: "Channels", href: `${request.baseUrl}/channels` },
236
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
237
+ { text: "Settings" },
238
+ ],
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Update channel settings
244
+ * @param {object} request - Express request
245
+ * @param {object} response - Express response
246
+ * @returns {Promise<void>}
247
+ */
248
+ export async function updateSettings(request, response) {
249
+ const { application } = request.app.locals;
250
+ const userId = getUserId(request);
251
+ const { uid } = request.params;
252
+ const { excludeTypes, excludeRegex } = request.body;
253
+
254
+ const channelDocument = await getChannel(application, uid, userId);
255
+ if (!channelDocument) {
256
+ return response.status(404).render("404");
257
+ }
258
+
259
+ const validatedTypes = validateExcludeTypes(
260
+ Array.isArray(excludeTypes) ? excludeTypes : [excludeTypes].filter(Boolean),
261
+ );
262
+ const validatedRegex = validateExcludeRegex(excludeRegex);
263
+
264
+ await updateChannelSettings(
265
+ application,
266
+ uid,
267
+ {
268
+ excludeTypes: validatedTypes,
269
+ excludeRegex: validatedRegex,
270
+ },
271
+ userId,
272
+ );
273
+
274
+ response.redirect(`${request.baseUrl}/channels/${uid}`);
275
+ }
276
+
277
+ /**
278
+ * Delete channel
279
+ * @param {object} request - Express request
280
+ * @param {object} response - Express response
281
+ * @returns {Promise<void>}
282
+ */
283
+ export async function deleteChannelAction(request, response) {
284
+ const { application } = request.app.locals;
285
+ const userId = getUserId(request);
286
+ const { uid } = request.params;
287
+
288
+ // Don't allow deleting system channels
289
+ if (uid === "notifications" || uid === "activitypub") {
290
+ return response.redirect(`${request.baseUrl}/channels`);
291
+ }
292
+
293
+ const channelDocument = await getChannel(application, uid, userId);
294
+ if (!channelDocument) {
295
+ return response.status(404).render("404");
296
+ }
297
+
298
+ await deleteChannel(application, uid, userId);
299
+
300
+ response.redirect(`${request.baseUrl}/channels`);
301
+ }