@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
@@ -1,1580 +0,0 @@
1
- /**
2
- * Reader UI controller
3
- * @module controllers/reader
4
- */
5
-
6
- import { discoverAndValidateFeeds, getBestFeed } from "../feeds/discovery.js";
7
- import { validateFeedUrl } from "../feeds/validator.js";
8
- import { ObjectId } from "mongodb";
9
- import { refreshFeedNow } from "../polling/scheduler.js";
10
- import {
11
- getChannels,
12
- getChannelsWithColors,
13
- getChannel,
14
- createChannel,
15
- updateChannelSettings,
16
- deleteChannel,
17
- } from "../storage/channels.js";
18
- import {
19
- getFeedsForChannel,
20
- getFeedById,
21
- createFeed,
22
- deleteFeed,
23
- updateFeed,
24
- updateFeedStatus,
25
- } from "../storage/feeds.js";
26
- import {
27
- getTimelineItems,
28
- getAllTimelineItems,
29
- getItemById,
30
- markItemsRead,
31
- countReadItems,
32
- } from "../storage/items.js";
33
- import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
34
- import { getUserId } from "../utils/auth.js";
35
- import {
36
- validateChannelName,
37
- validateExcludeTypes,
38
- validateExcludeRegex,
39
- } from "../utils/validation.js";
40
- import { proxyItemImages } from "../media/proxy.js";
41
- import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
42
-
43
- /**
44
- * Reader index - redirect to channels
45
- * @param {object} request - Express request
46
- * @param {object} response - Express response
47
- */
48
- export async function index(request, response) {
49
- const lastView = request.session?.microsubView || "timeline";
50
- const validViews = ["channels", "deck", "timeline"];
51
- const view = validViews.includes(lastView) ? lastView : "timeline";
52
- response.redirect(`${request.baseUrl}/${view}`);
53
- }
54
-
55
- /**
56
- * List channels
57
- * @param {object} request - Express request
58
- * @param {object} response - Express response
59
- */
60
- export async function channels(request, response) {
61
- const { application } = request.app.locals;
62
- const userId = getUserId(request);
63
-
64
- const channelList = await getChannels(application, userId);
65
-
66
- if (request.session) request.session.microsubView = "channels";
67
-
68
- response.render("reader", {
69
- title: request.__("microsub.views.channels"),
70
- channels: channelList,
71
- baseUrl: request.baseUrl,
72
- readerBaseUrl: request.baseUrl,
73
- activeView: "channels",
74
- breadcrumbs: [
75
- { text: "Reader", href: request.baseUrl },
76
- { text: "Channels" },
77
- ],
78
- });
79
- }
80
-
81
- /**
82
- * New channel form
83
- * @param {object} request - Express request
84
- * @param {object} response - Express response
85
- */
86
- export async function newChannel(request, response) {
87
- response.render("channel-new", {
88
- title: request.__("microsub.channels.new"),
89
- baseUrl: request.baseUrl,
90
- readerBaseUrl: request.baseUrl,
91
- activeView: "channels",
92
- breadcrumbs: [
93
- { text: "Reader", href: request.baseUrl },
94
- { text: "Channels", href: `${request.baseUrl}/channels` },
95
- { text: request.__("microsub.channels.new") },
96
- ],
97
- });
98
- }
99
-
100
- /**
101
- * Create channel
102
- * @param {object} request - Express request
103
- * @param {object} response - Express response
104
- */
105
- export async function createChannelAction(request, response) {
106
- const { application } = request.app.locals;
107
- const userId = getUserId(request);
108
- const { name } = request.body;
109
-
110
- validateChannelName(name);
111
-
112
- await createChannel(application, { name, userId });
113
-
114
- response.redirect(`${request.baseUrl}/channels`);
115
- }
116
-
117
- /**
118
- * View channel timeline
119
- * @param {object} request - Express request
120
- * @param {object} response - Express response
121
- * @returns {Promise<void>}
122
- */
123
- export async function channel(request, response) {
124
- const { application } = request.app.locals;
125
- const userId = getUserId(request);
126
- const { uid } = request.params;
127
- const { before, after, showRead } = request.query;
128
-
129
- const channelDocument = await getChannel(application, uid, userId);
130
- if (!channelDocument) {
131
- return response.status(404).render("404");
132
- }
133
-
134
- // Check if showing read items
135
- const showReadItems = showRead === "true";
136
-
137
- const timeline = await getTimelineItems(application, channelDocument._id, {
138
- before,
139
- after,
140
- userId,
141
- showRead: showReadItems,
142
- });
143
-
144
- // Proxy images through media endpoint for privacy
145
- const proxyBaseUrl = application.url;
146
- if (proxyBaseUrl && timeline.items) {
147
- timeline.items = timeline.items.map((item) =>
148
- proxyItemImages(item, proxyBaseUrl),
149
- );
150
- }
151
-
152
- // Count read items to show "View read items" button
153
- const readCount = await countReadItems(
154
- application,
155
- channelDocument._id,
156
- userId,
157
- );
158
-
159
- response.render("channel", {
160
- title: channelDocument.name,
161
- channel: channelDocument,
162
- items: timeline.items,
163
- paging: timeline.paging,
164
- readCount,
165
- showRead: showReadItems,
166
- baseUrl: request.baseUrl,
167
- readerBaseUrl: request.baseUrl,
168
- activeView: "channels",
169
- breadcrumbs: [
170
- { text: "Reader", href: request.baseUrl },
171
- { text: "Channels", href: `${request.baseUrl}/channels` },
172
- { text: channelDocument.name },
173
- ],
174
- });
175
- }
176
-
177
- /**
178
- * Channel settings form
179
- * @param {object} request - Express request
180
- * @param {object} response - Express response
181
- * @returns {Promise<void>}
182
- */
183
- export async function settings(request, response) {
184
- const { application } = request.app.locals;
185
- const userId = getUserId(request);
186
- const { uid } = request.params;
187
-
188
- const channelDocument = await getChannel(application, uid, userId);
189
- if (!channelDocument) {
190
- return response.status(404).render("404");
191
- }
192
-
193
- response.render("settings", {
194
- title: request.__("microsub.settings.title", {
195
- channel: channelDocument.name,
196
- }),
197
- channel: channelDocument,
198
- baseUrl: request.baseUrl,
199
- readerBaseUrl: request.baseUrl,
200
- activeView: "channels",
201
- breadcrumbs: [
202
- { text: "Reader", href: request.baseUrl },
203
- { text: "Channels", href: `${request.baseUrl}/channels` },
204
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
205
- { text: "Settings" },
206
- ],
207
- });
208
- }
209
-
210
- /**
211
- * Update channel settings
212
- * @param {object} request - Express request
213
- * @param {object} response - Express response
214
- * @returns {Promise<void>}
215
- */
216
- export async function updateSettings(request, response) {
217
- const { application } = request.app.locals;
218
- const userId = getUserId(request);
219
- const { uid } = request.params;
220
- const { excludeTypes, excludeRegex } = request.body;
221
-
222
- const channelDocument = await getChannel(application, uid, userId);
223
- if (!channelDocument) {
224
- return response.status(404).render("404");
225
- }
226
-
227
- const validatedTypes = validateExcludeTypes(
228
- Array.isArray(excludeTypes) ? excludeTypes : [excludeTypes].filter(Boolean),
229
- );
230
- const validatedRegex = validateExcludeRegex(excludeRegex);
231
-
232
- await updateChannelSettings(
233
- application,
234
- uid,
235
- {
236
- excludeTypes: validatedTypes,
237
- excludeRegex: validatedRegex,
238
- },
239
- userId,
240
- );
241
-
242
- response.redirect(`${request.baseUrl}/channels/${uid}`);
243
- }
244
-
245
- /**
246
- * Delete channel
247
- * @param {object} request - Express request
248
- * @param {object} response - Express response
249
- * @returns {Promise<void>}
250
- */
251
- export async function deleteChannelAction(request, response) {
252
- const { application } = request.app.locals;
253
- const userId = getUserId(request);
254
- const { uid } = request.params;
255
-
256
- // Don't allow deleting system channels
257
- if (uid === "notifications" || uid === "activitypub") {
258
- return response.redirect(`${request.baseUrl}/channels`);
259
- }
260
-
261
- const channelDocument = await getChannel(application, uid, userId);
262
- if (!channelDocument) {
263
- return response.status(404).render("404");
264
- }
265
-
266
- await deleteChannel(application, uid, userId);
267
-
268
- response.redirect(`${request.baseUrl}/channels`);
269
- }
270
-
271
- /**
272
- * View feeds for a channel
273
- * @param {object} request - Express request
274
- * @param {object} response - Express response
275
- * @returns {Promise<void>}
276
- */
277
- export async function feeds(request, response) {
278
- const { application } = request.app.locals;
279
- const userId = getUserId(request);
280
- const { uid } = request.params;
281
-
282
- const channelDocument = await getChannel(application, uid, userId);
283
- if (!channelDocument) {
284
- return response.status(404).render("404");
285
- }
286
-
287
- const feedList = await getFeedsForChannel(application, channelDocument._id);
288
-
289
- response.render("feeds", {
290
- title: request.__("microsub.feeds.title"),
291
- channel: channelDocument,
292
- feeds: feedList,
293
- baseUrl: request.baseUrl,
294
- readerBaseUrl: request.baseUrl,
295
- activeView: "channels",
296
- breadcrumbs: [
297
- { text: "Reader", href: request.baseUrl },
298
- { text: "Channels", href: `${request.baseUrl}/channels` },
299
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
300
- { text: "Feeds" },
301
- ],
302
- });
303
- }
304
-
305
- /**
306
- * Add feed to channel
307
- * @param {object} request - Express request
308
- * @param {object} response - Express response
309
- * @returns {Promise<void>}
310
- */
311
- export async function addFeed(request, response) {
312
- const { application } = request.app.locals;
313
- const userId = getUserId(request);
314
- const { uid } = request.params;
315
- const { url } = request.body;
316
-
317
- const channelDocument = await getChannel(application, uid, userId);
318
- if (!channelDocument) {
319
- return response.status(404).render("404");
320
- }
321
-
322
- try {
323
- // Create feed subscription (throws DUPLICATE_FEED if already exists)
324
- const feed = await createFeed(application, {
325
- channelId: channelDocument._id,
326
- url,
327
- title: undefined,
328
- photo: undefined,
329
- });
330
-
331
- // Trigger immediate fetch in background
332
- refreshFeedNow(application, feed._id).catch((error) => {
333
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
334
- });
335
-
336
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
337
- } catch (error) {
338
- if (error.code === "DUPLICATE_FEED") {
339
- // Re-render feeds page with error message
340
- const feedList = await getFeedsForChannel(application, channelDocument._id);
341
- return response.render("feeds", {
342
- title: request.__("microsub.feeds.title"),
343
- channel: channelDocument,
344
- feeds: feedList,
345
- baseUrl: request.baseUrl,
346
- readerBaseUrl: request.baseUrl,
347
- activeView: "channels",
348
- error: `This feed already exists in channel "${error.channelName}"`,
349
- breadcrumbs: [
350
- { text: "Reader", href: request.baseUrl },
351
- { text: "Channels", href: `${request.baseUrl}/channels` },
352
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
353
- { text: "Feeds" },
354
- ],
355
- });
356
- }
357
- throw error;
358
- }
359
- }
360
-
361
- /**
362
- * Remove feed from channel
363
- * @param {object} request - Express request
364
- * @param {object} response - Express response
365
- * @returns {Promise<void>}
366
- */
367
- export async function removeFeed(request, response) {
368
- const { application } = request.app.locals;
369
- const userId = getUserId(request);
370
- const { uid } = request.params;
371
- const { url } = request.body;
372
-
373
- const channelDocument = await getChannel(application, uid, userId);
374
- if (!channelDocument) {
375
- return response.status(404).render("404");
376
- }
377
-
378
- await deleteFeed(application, channelDocument._id, url);
379
-
380
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
381
- }
382
-
383
- /**
384
- * View single item
385
- * @param {object} request - Express request
386
- * @param {object} response - Express response
387
- * @returns {Promise<void>}
388
- */
389
- export async function item(request, response) {
390
- const { application } = request.app.locals;
391
- const userId = getUserId(request);
392
- const { id } = request.params;
393
-
394
- const itemDocument = await getItemById(application, id, userId);
395
- if (!itemDocument) {
396
- return response.status(404).render("404");
397
- }
398
-
399
- // Get the channel for this item (needed for mark-read)
400
- let channel = null;
401
- if (itemDocument.channelId) {
402
- const channelsCollection = application.collections.get("microsub_channels");
403
- channel = await channelsCollection.findOne({ _id: itemDocument.channelId });
404
- }
405
-
406
- const itemBreadcrumbs = [
407
- { text: "Reader", href: request.baseUrl },
408
- ];
409
- if (channel) {
410
- itemBreadcrumbs.push(
411
- { text: "Channels", href: `${request.baseUrl}/channels` },
412
- { text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
413
- );
414
- }
415
- itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
416
-
417
- response.render("item", {
418
- title: itemDocument.name || "Item",
419
- item: itemDocument,
420
- channel,
421
- baseUrl: request.baseUrl,
422
- readerBaseUrl: request.baseUrl,
423
- activeView: "channels",
424
- breadcrumbs: itemBreadcrumbs,
425
- });
426
- }
427
-
428
- /**
429
- * Ensure value is a string URL
430
- * @param {string|object|undefined} value - Value to check
431
- * @returns {string|undefined} String value or undefined
432
- */
433
- function ensureString(value) {
434
- if (!value) return;
435
- if (typeof value === "string") return value;
436
- if (typeof value === "object" && value.url) return value.url;
437
- return String(value);
438
- }
439
-
440
- /**
441
- * Detect the protocol of a URL for auto-syndication targeting
442
- * @param {string} url - URL to classify
443
- * @returns {string} "atmosphere" | "fediverse" | "web"
444
- */
445
- function detectProtocol(url) {
446
- if (!url || typeof url !== "string") return "web";
447
- const lower = url.toLowerCase();
448
- if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
449
- if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
450
- lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse";
451
- return "web";
452
- }
453
-
454
- /**
455
- * Fetch syndication targets from Micropub config
456
- * @param {object} application - Indiekit application
457
- * @param {string} token - Auth token
458
- * @returns {Promise<Array>} Syndication targets
459
- */
460
- async function getSyndicationTargets(application, token) {
461
- try {
462
- const micropubEndpoint = application.micropubEndpoint;
463
- if (!micropubEndpoint) return [];
464
-
465
- const micropubUrl = micropubEndpoint.startsWith("http")
466
- ? micropubEndpoint
467
- : new URL(micropubEndpoint, application.url).href;
468
-
469
- const configUrl = `${micropubUrl}?q=config`;
470
- const configResponse = await fetch(configUrl, {
471
- headers: {
472
- Authorization: `Bearer ${token}`,
473
- Accept: "application/json",
474
- },
475
- });
476
-
477
- if (!configResponse.ok) return [];
478
-
479
- const config = await configResponse.json();
480
- return config["syndicate-to"] || [];
481
- } catch {
482
- return [];
483
- }
484
- }
485
-
486
- /**
487
- * Compose response form
488
- * @param {object} request - Express request
489
- * @param {object} response - Express response
490
- * @returns {Promise<void>}
491
- */
492
- export async function compose(request, response) {
493
- const { application } = request.app.locals;
494
-
495
- // Support both long-form (replyTo) and short-form (reply) query params
496
- const {
497
- replyTo,
498
- reply,
499
- likeOf,
500
- like,
501
- repostOf,
502
- repost,
503
- bookmarkOf,
504
- bookmark,
505
- } = request.query;
506
-
507
- // Fetch syndication targets if user is authenticated
508
- const token = request.session?.access_token;
509
- const syndicationTargets = token
510
- ? await getSyndicationTargets(application, token)
511
- : [];
512
-
513
- // Auto-select syndication target based on interaction URL protocol
514
- const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
515
- if (interactionUrl && syndicationTargets.length > 0) {
516
- const protocol = detectProtocol(interactionUrl);
517
- for (const target of syndicationTargets) {
518
- const targetId = (target.uid || target.name || "").toLowerCase();
519
- if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
520
- target.checked = true;
521
- } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
522
- target.checked = true;
523
- }
524
- }
525
- }
526
-
527
- response.render("compose", {
528
- title: request.__("microsub.compose.title"),
529
- replyTo: ensureString(replyTo || reply),
530
- likeOf: ensureString(likeOf || like),
531
- repostOf: ensureString(repostOf || repost),
532
- bookmarkOf: ensureString(bookmarkOf || bookmark),
533
- syndicationTargets,
534
- baseUrl: request.baseUrl,
535
- readerBaseUrl: request.baseUrl,
536
- activeView: "channels",
537
- breadcrumbs: [
538
- { text: "Reader", href: request.baseUrl },
539
- { text: "Compose" },
540
- ],
541
- });
542
- }
543
-
544
- /**
545
- * Submit composed response via Micropub
546
- * @param {object} request - Express request
547
- * @param {object} response - Express response
548
- * @returns {Promise<void>}
549
- */
550
- export async function submitCompose(request, response) {
551
- const { application } = request.app.locals;
552
- const { content } = request.body;
553
- const inReplyTo = request.body["in-reply-to"];
554
- const likeOf = request.body["like-of"];
555
- const repostOf = request.body["repost-of"];
556
- const bookmarkOf = request.body["bookmark-of"];
557
- const syndicateTo = request.body["mp-syndicate-to"];
558
-
559
- // Debug logging
560
- console.info(
561
- "[Microsub] submitCompose request.body:",
562
- JSON.stringify(request.body),
563
- );
564
- console.info("[Microsub] Extracted values:", {
565
- content,
566
- inReplyTo,
567
- likeOf,
568
- repostOf,
569
- bookmarkOf,
570
- syndicateTo,
571
- });
572
-
573
- // Get Micropub endpoint
574
- const micropubEndpoint = application.micropubEndpoint;
575
- if (!micropubEndpoint) {
576
- return response.status(500).render("error", {
577
- title: "Error",
578
- content: "Micropub endpoint not configured",
579
- });
580
- }
581
-
582
- // Build absolute Micropub URL
583
- const micropubUrl = micropubEndpoint.startsWith("http")
584
- ? micropubEndpoint
585
- : new URL(micropubEndpoint, application.url).href;
586
-
587
- // Get auth token from session
588
- const token = request.session?.access_token;
589
- if (!token) {
590
- return response.redirect("/session/login?redirect=" + request.originalUrl);
591
- }
592
-
593
- // Build Micropub request body
594
- const micropubData = new URLSearchParams();
595
- micropubData.append("h", "entry");
596
-
597
- if (likeOf) {
598
- // Like post - content is optional comment
599
- micropubData.append("like-of", likeOf);
600
- if (content && content.trim()) {
601
- micropubData.append("content", content.trim());
602
- }
603
- } else if (repostOf) {
604
- // Repost - content is optional comment
605
- micropubData.append("repost-of", repostOf);
606
- if (content && content.trim()) {
607
- micropubData.append("content", content.trim());
608
- }
609
- } else if (bookmarkOf) {
610
- // Bookmark - content is optional comment
611
- micropubData.append("bookmark-of", bookmarkOf);
612
- if (content && content.trim()) {
613
- micropubData.append("content", content.trim());
614
- }
615
- } else if (inReplyTo) {
616
- // Reply
617
- micropubData.append("in-reply-to", inReplyTo);
618
- micropubData.append("content", content || "");
619
- } else {
620
- // Regular note
621
- micropubData.append("content", content || "");
622
- }
623
-
624
- // Add syndication targets
625
- if (syndicateTo) {
626
- const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];
627
- for (const target of targets) {
628
- micropubData.append("mp-syndicate-to", target);
629
- }
630
- }
631
-
632
- // Debug: log what we're sending
633
- console.info("[Microsub] Sending to Micropub:", {
634
- url: micropubUrl,
635
- body: micropubData.toString(),
636
- });
637
-
638
- try {
639
- const micropubResponse = await fetch(micropubUrl, {
640
- method: "POST",
641
- headers: {
642
- Authorization: `Bearer ${token}`,
643
- "Content-Type": "application/x-www-form-urlencoded",
644
- Accept: "application/json",
645
- },
646
- body: micropubData.toString(),
647
- });
648
-
649
- if (
650
- micropubResponse.ok ||
651
- micropubResponse.status === 201 ||
652
- micropubResponse.status === 202
653
- ) {
654
- // Success - get the Location header for the new post URL
655
- const location = micropubResponse.headers.get("Location");
656
- console.info(
657
- `[Microsub] Created post via Micropub: ${location || "success"}`,
658
- );
659
-
660
- // Redirect back to reader with success message
661
- return response.redirect(`${request.baseUrl}/channels`);
662
- }
663
-
664
- // Handle error
665
- const errorBody = await micropubResponse.text();
666
- const statusText = micropubResponse.statusText || "Unknown error";
667
- console.error(
668
- `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
669
- );
670
-
671
- // Parse error message from response body if JSON
672
- let errorMessage = `Micropub error: ${statusText}`;
673
- try {
674
- const errorJson = JSON.parse(errorBody);
675
- if (errorJson.error_description) {
676
- errorMessage = String(errorJson.error_description);
677
- } else if (errorJson.error) {
678
- errorMessage = String(errorJson.error);
679
- }
680
- } catch {
681
- // Not JSON, use status text
682
- }
683
-
684
- return response.status(micropubResponse.status).render("error", {
685
- title: "Error",
686
- content: errorMessage,
687
- });
688
- } catch (error) {
689
- console.error(`[Microsub] Micropub request failed: ${error.message}`);
690
-
691
- return response.status(500).render("error", {
692
- title: "Error",
693
- content: `Failed to create post: ${error.message}`,
694
- });
695
- }
696
- }
697
-
698
- /**
699
- * Search/discover feeds page
700
- * @param {object} request - Express request
701
- * @param {object} response - Express response
702
- * @returns {Promise<void>}
703
- */
704
- export async function searchPage(request, response) {
705
- const { application } = request.app.locals;
706
- const userId = getUserId(request);
707
-
708
- const channelList = await getChannels(application, userId);
709
-
710
- response.render("search", {
711
- title: request.__("microsub.search.title"),
712
- channels: channelList,
713
- baseUrl: request.baseUrl,
714
- readerBaseUrl: request.baseUrl,
715
- activeView: "channels",
716
- breadcrumbs: [
717
- { text: "Reader", href: request.baseUrl },
718
- { text: "Search" },
719
- ],
720
- });
721
- }
722
-
723
- /**
724
- * Search for feeds from URL - enhanced with validation
725
- * @param {object} request - Express request
726
- * @param {object} response - Express response
727
- * @returns {Promise<void>}
728
- */
729
- export async function searchFeeds(request, response) {
730
- const { application } = request.app.locals;
731
- const userId = getUserId(request);
732
- const { query } = request.body;
733
-
734
- const channelList = await getChannels(application, userId);
735
-
736
- let results = [];
737
- let discoveryError = null;
738
-
739
- if (query) {
740
- try {
741
- // Use enhanced discovery with validation
742
- results = await discoverAndValidateFeeds(query);
743
- } catch (error) {
744
- discoveryError = error.message;
745
- }
746
- }
747
-
748
- response.render("search", {
749
- title: request.__("microsub.search.title"),
750
- channels: channelList,
751
- query,
752
- results,
753
- discoveryError,
754
- searched: true,
755
- baseUrl: request.baseUrl,
756
- readerBaseUrl: request.baseUrl,
757
- activeView: "channels",
758
- breadcrumbs: [
759
- { text: "Reader", href: request.baseUrl },
760
- { text: "Search" },
761
- ],
762
- });
763
- }
764
-
765
- /**
766
- * Subscribe to a feed from search results - with validation
767
- * @param {object} request - Express request
768
- * @param {object} response - Express response
769
- * @returns {Promise<void>}
770
- */
771
- export async function subscribe(request, response) {
772
- const { application } = request.app.locals;
773
- const userId = getUserId(request);
774
- const { url, channel: channelUid, skipValidation } = request.body;
775
-
776
- const channelDocument = await getChannel(application, channelUid, userId);
777
- if (!channelDocument) {
778
- return response.status(404).render("404");
779
- }
780
-
781
- // Validate feed unless explicitly skipped (for power users)
782
- if (!skipValidation) {
783
- const validation = await validateFeedUrl(url);
784
-
785
- if (!validation.valid) {
786
- const channelList = await getChannels(application, userId);
787
- return response.render("search", {
788
- title: request.__("microsub.search.title"),
789
- channels: channelList,
790
- query: url,
791
- validationError: validation.error,
792
- baseUrl: request.baseUrl,
793
- readerBaseUrl: request.baseUrl,
794
- activeView: "channels",
795
- breadcrumbs: [
796
- { text: "Reader", href: request.baseUrl },
797
- { text: "Search" },
798
- ],
799
- });
800
- }
801
-
802
- // Warn about comments feeds but allow subscription
803
- if (validation.isCommentsFeed) {
804
- console.warn(`[Microsub] Subscribing to comments feed: ${url}`);
805
- }
806
- }
807
-
808
- // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
809
- try {
810
- const feed = await createFeed(application, {
811
- channelId: channelDocument._id,
812
- url,
813
- title: undefined,
814
- photo: undefined,
815
- });
816
-
817
- // Trigger immediate fetch in background
818
- refreshFeedNow(application, feed._id).catch((error) => {
819
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
820
- });
821
-
822
- response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
823
- } catch (error) {
824
- if (error.code === "DUPLICATE_FEED") {
825
- const channelList = await getChannels(application, userId);
826
- return response.render("search", {
827
- title: request.__("microsub.search.title"),
828
- channels: channelList,
829
- query: url,
830
- validationError: `This feed already exists in channel "${error.channelName}"`,
831
- baseUrl: request.baseUrl,
832
- readerBaseUrl: request.baseUrl,
833
- activeView: "channels",
834
- breadcrumbs: [
835
- { text: "Reader", href: request.baseUrl },
836
- { text: "Search" },
837
- ],
838
- });
839
- }
840
- throw error;
841
- }
842
- }
843
-
844
- /**
845
- * Mark all items in channel as read
846
- * @param {object} request - Express request
847
- * @param {object} response - Express response
848
- * @returns {Promise<void>}
849
- */
850
- export async function markAllRead(request, response) {
851
- const { application } = request.app.locals;
852
- const userId = getUserId(request);
853
- const { channel: channelUid } = request.body;
854
-
855
- const channelDocument = await getChannel(application, channelUid, userId);
856
- if (!channelDocument) {
857
- return response.status(404).render("404");
858
- }
859
-
860
- // Mark all items as read using the special "last-read-entry" value
861
- await markItemsRead(
862
- application,
863
- channelDocument._id,
864
- ["last-read-entry"],
865
- userId,
866
- );
867
-
868
- response.redirect(`${request.baseUrl}/channels/${channelUid}`);
869
- }
870
-
871
- /**
872
- * Return rendered HTML fragments for infinite scroll
873
- * @param {object} request - Express request
874
- * @param {object} response - Express response
875
- * @returns {Promise<void>}
876
- */
877
- export async function channelHtml(request, response) {
878
- const { application } = request.app.locals;
879
- const userId = getUserId(request);
880
- const { uid } = request.params;
881
- const { before, after, showRead } = request.query;
882
-
883
- const channelDocument = await getChannel(application, uid, userId);
884
- if (!channelDocument) {
885
- return response.status(404).json({ error: "Channel not found" });
886
- }
887
-
888
- const showReadItems = showRead === "true";
889
-
890
- const timeline = await getTimelineItems(application, channelDocument._id, {
891
- before,
892
- after,
893
- userId,
894
- showRead: showReadItems,
895
- });
896
-
897
- // Proxy images
898
- const proxyBaseUrl = application.url;
899
- if (proxyBaseUrl && timeline.items) {
900
- timeline.items = timeline.items.map((item) =>
901
- proxyItemImages(item, proxyBaseUrl),
902
- );
903
- }
904
-
905
- // Render items via layout-less fragment template (standard response.render
906
- // with callback returns HTML string without sending a response)
907
- const fragmentHtml = await new Promise((resolve, reject) => {
908
- response.render("partials/items-fragment", {
909
- items: timeline.items,
910
- channel: channelDocument,
911
- baseUrl: request.baseUrl,
912
- }, (error, html) => error ? reject(error) : resolve(html));
913
- });
914
-
915
- response.json({
916
- html: fragmentHtml,
917
- paging: timeline.paging,
918
- count: timeline.items.length,
919
- });
920
- }
921
-
922
- /**
923
- * Return rendered HTML fragments for timeline infinite scroll
924
- * @param {object} request - Express request
925
- * @param {object} response - Express response
926
- * @returns {Promise<void>}
927
- */
928
- export async function timelineHtml(request, response) {
929
- const { application } = request.app.locals;
930
- const userId = getUserId(request);
931
- const { before, after } = request.query;
932
-
933
- const channelList = await getChannelsWithColors(application, userId);
934
- const channelMap = new Map();
935
- for (const ch of channelList) {
936
- channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
937
- }
938
-
939
- const excludeParam = request.query.exclude;
940
- const excludeIds = excludeParam
941
- ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
942
- : [];
943
-
944
- const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
945
- const excludeChannelIds = [...excludeIds];
946
- if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
947
- excludeChannelIds.push(notificationsChannel._id.toString());
948
- }
949
-
950
- const result = await getAllTimelineItems(application, {
951
- before,
952
- after,
953
- userId,
954
- excludeChannelIds,
955
- });
956
-
957
- const proxyBaseUrl = application.url;
958
- if (proxyBaseUrl && result.items) {
959
- result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
960
- }
961
-
962
- for (const item of result.items) {
963
- if (item._channelId) {
964
- const info = channelMap.get(item._channelId);
965
- if (info) {
966
- item._channelName = info.name;
967
- item._channelColor = info.color;
968
- item._channelUid = info.uid;
969
- }
970
- }
971
- }
972
-
973
- const fragmentHtml = await new Promise((resolve, reject) => {
974
- response.render("partials/items-fragment-timeline", {
975
- items: result.items,
976
- baseUrl: request.baseUrl,
977
- }, (error, html) => error ? reject(error) : resolve(html));
978
- });
979
-
980
- response.json({
981
- html: fragmentHtml,
982
- paging: result.paging,
983
- count: result.items.length,
984
- });
985
- }
986
-
987
- /**
988
- * Mark specific items as read (no-JS form fallback for mark-view-as-read)
989
- * @param {object} request - Express request
990
- * @param {object} response - Express response
991
- */
992
- export async function markViewRead(request, response) {
993
- const { application } = request.app.locals;
994
- const userId = getUserId(request);
995
- const { channel: channelUid } = request.body;
996
- let { entry } = request.body;
997
-
998
- const channelDocument = await getChannel(application, channelUid, userId);
999
- if (!channelDocument) {
1000
- return response.status(404).render("404");
1001
- }
1002
-
1003
- const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
1004
- if (entryIds.length > 0) {
1005
- await markItemsRead(application, channelDocument._id, entryIds, userId);
1006
- }
1007
-
1008
- response.redirect(`${request.baseUrl}/channels/${channelUid}`);
1009
- }
1010
-
1011
- /**
1012
- * View single feed details with status - redirects to edit form
1013
- * @param {object} request - Express request
1014
- * @param {object} response - Express response
1015
- * @returns {Promise<void>}
1016
- */
1017
- export async function feedDetails(request, response) {
1018
- const { uid, feedId } = request.params;
1019
- // Redirect to edit form which shows all details
1020
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
1021
- }
1022
-
1023
- /**
1024
- * Edit feed URL form
1025
- * @param {object} request - Express request
1026
- * @param {object} response - Express response
1027
- * @returns {Promise<void>}
1028
- */
1029
- export async function editFeedForm(request, response) {
1030
- const { application } = request.app.locals;
1031
- const userId = getUserId(request);
1032
- const { uid, feedId } = request.params;
1033
-
1034
- const channelDocument = await getChannel(application, uid, userId);
1035
- if (!channelDocument) {
1036
- return response.status(404).render("404");
1037
- }
1038
-
1039
- const feed = await getFeedById(application, feedId);
1040
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1041
- return response.status(404).render("404");
1042
- }
1043
-
1044
- response.render("feed-edit", {
1045
- title: request.__("microsub.feeds.edit"),
1046
- channel: channelDocument,
1047
- feed,
1048
- baseUrl: request.baseUrl,
1049
- readerBaseUrl: request.baseUrl,
1050
- activeView: "channels",
1051
- breadcrumbs: [
1052
- { text: "Reader", href: request.baseUrl },
1053
- { text: "Channels", href: `${request.baseUrl}/channels` },
1054
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
1055
- { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
1056
- { text: "Edit" },
1057
- ],
1058
- });
1059
- }
1060
-
1061
- /**
1062
- * Update feed URL
1063
- * @param {object} request - Express request
1064
- * @param {object} response - Express response
1065
- * @returns {Promise<void>}
1066
- */
1067
- export async function updateFeedUrl(request, response) {
1068
- const { application } = request.app.locals;
1069
- const userId = getUserId(request);
1070
- const { uid, feedId } = request.params;
1071
- const { url: newUrl } = request.body;
1072
-
1073
- const channelDocument = await getChannel(application, uid, userId);
1074
- if (!channelDocument) {
1075
- return response.status(404).render("404");
1076
- }
1077
-
1078
- const feed = await getFeedById(application, feedId);
1079
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1080
- return response.status(404).render("404");
1081
- }
1082
-
1083
- // Validate the new URL is a valid feed
1084
- const validation = await validateFeedUrl(newUrl);
1085
-
1086
- if (!validation.valid) {
1087
- return response.render("feed-edit", {
1088
- title: request.__("microsub.feeds.edit"),
1089
- channel: channelDocument,
1090
- feed,
1091
- error: validation.error,
1092
- baseUrl: request.baseUrl,
1093
- readerBaseUrl: request.baseUrl,
1094
- activeView: "channels",
1095
- breadcrumbs: [
1096
- { text: "Reader", href: request.baseUrl },
1097
- { text: "Channels", href: `${request.baseUrl}/channels` },
1098
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
1099
- { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
1100
- { text: "Edit" },
1101
- ],
1102
- });
1103
- }
1104
-
1105
- // Update the feed URL and reset error state
1106
- await updateFeed(application, feedId, {
1107
- url: newUrl,
1108
- title: validation.title || feed.title,
1109
- status: "active",
1110
- lastError: undefined,
1111
- lastErrorAt: undefined,
1112
- consecutiveErrors: 0,
1113
- });
1114
-
1115
- // Trigger immediate fetch
1116
- refreshFeedNow(application, feedId).catch((error) => {
1117
- console.error(
1118
- `[Microsub] Error refreshing updated feed ${newUrl}:`,
1119
- error.message,
1120
- );
1121
- });
1122
-
1123
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
1124
- }
1125
-
1126
- /**
1127
- * Rediscover feed - run discovery on URL to find actual RSS feed
1128
- * @param {object} request - Express request
1129
- * @param {object} response - Express response
1130
- * @returns {Promise<void>}
1131
- */
1132
- export async function rediscoverFeed(request, response) {
1133
- const { application } = request.app.locals;
1134
- const userId = getUserId(request);
1135
- const { uid, feedId } = request.params;
1136
-
1137
- const channelDocument = await getChannel(application, uid, userId);
1138
- if (!channelDocument) {
1139
- return response.status(404).render("404");
1140
- }
1141
-
1142
- const feed = await getFeedById(application, feedId);
1143
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1144
- return response.status(404).render("404");
1145
- }
1146
-
1147
- // Run feed discovery on the current URL
1148
- try {
1149
- const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
1150
- const bestFeed = getBestFeed(discoveredFeeds);
1151
-
1152
- if (bestFeed && bestFeed.url !== feed.url) {
1153
- // Found a different (better) feed URL - update the record
1154
- await updateFeed(application, feedId, {
1155
- url: bestFeed.url,
1156
- title: bestFeed.title || feed.title,
1157
- status: "active",
1158
- lastError: undefined,
1159
- lastErrorAt: undefined,
1160
- consecutiveErrors: 0,
1161
- });
1162
-
1163
- console.info(
1164
- `[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
1165
- );
1166
-
1167
- // Trigger immediate fetch
1168
- refreshFeedNow(application, feedId).catch((error) => {
1169
- console.error(
1170
- `[Microsub] Error refreshing rediscovered feed:`,
1171
- error.message,
1172
- );
1173
- });
1174
- } else if (bestFeed) {
1175
- // Same URL but valid - just reset error state and refresh
1176
- await updateFeedStatus(application, feedId, { success: true });
1177
- await updateFeed(application, feedId, {
1178
- status: "active",
1179
- lastError: undefined,
1180
- lastErrorAt: undefined,
1181
- consecutiveErrors: 0,
1182
- });
1183
-
1184
- refreshFeedNow(application, feedId).catch((error) => {
1185
- console.error(`[Microsub] Error refreshing feed:`, error.message);
1186
- });
1187
- } else {
1188
- // No valid feed found
1189
- await updateFeedStatus(application, feedId, {
1190
- success: false,
1191
- error: "No valid feed found at this URL",
1192
- });
1193
- }
1194
- } catch (error) {
1195
- await updateFeedStatus(application, feedId, {
1196
- success: false,
1197
- error: error.message,
1198
- });
1199
- }
1200
-
1201
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
1202
- }
1203
-
1204
- /**
1205
- * Force refresh a feed
1206
- * @param {object} request - Express request
1207
- * @param {object} response - Express response
1208
- * @returns {Promise<void>}
1209
- */
1210
- export async function refreshFeed(request, response) {
1211
- const { application } = request.app.locals;
1212
- const userId = getUserId(request);
1213
- const { uid, feedId } = request.params;
1214
-
1215
- const channelDocument = await getChannel(application, uid, userId);
1216
- if (!channelDocument) {
1217
- return response.status(404).render("404");
1218
- }
1219
-
1220
- const feed = await getFeedById(application, feedId);
1221
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1222
- return response.status(404).render("404");
1223
- }
1224
-
1225
- // Trigger immediate fetch
1226
- refreshFeedNow(application, feedId).catch((error) => {
1227
- console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
1228
- });
1229
-
1230
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
1231
- }
1232
-
1233
- /**
1234
- * Actor profile — fetch and display a remote AP actor's recent posts
1235
- * @param {object} request - Express request
1236
- * @param {object} response - Express response
1237
- */
1238
- /**
1239
- * Find the ActivityPub plugin instance from installed plugins.
1240
- * @param {object} request - Express request
1241
- * @returns {object|undefined} The AP plugin instance
1242
- */
1243
- function getApPlugin(request) {
1244
- const installedPlugins = request.app.locals.installedPlugins;
1245
- if (!installedPlugins) return undefined;
1246
- return [...installedPlugins].find(
1247
- (p) => p.name === "ActivityPub endpoint",
1248
- );
1249
- }
1250
-
1251
- export async function actorProfile(request, response) {
1252
- const actorUrl = request.query.url;
1253
- if (!actorUrl) {
1254
- return response.status(400).render("404");
1255
- }
1256
-
1257
- // Check if we already follow this actor
1258
- const { application } = request.app.locals;
1259
- const apFollowing = application?.collections?.get("ap_following");
1260
- let isFollowing = false;
1261
- if (apFollowing) {
1262
- const existing = await apFollowing.findOne({ actorUrl });
1263
- isFollowing = !!existing;
1264
- }
1265
-
1266
- // Check if AP plugin is available (for follow button visibility)
1267
- const apPlugin = getApPlugin(request);
1268
- const canFollow = !!apPlugin;
1269
-
1270
- try {
1271
- const { actor, items } = await fetchActorOutbox(actorUrl, { limit: 30 });
1272
-
1273
- response.render("actor", {
1274
- title: actor.name || "Actor",
1275
- actor,
1276
- items,
1277
- actorUrl,
1278
- isFollowing,
1279
- canFollow,
1280
- baseUrl: request.baseUrl,
1281
- readerBaseUrl: request.baseUrl,
1282
- activeView: "channels",
1283
- breadcrumbs: [
1284
- { text: "Reader", href: request.baseUrl },
1285
- { text: actor.name || "Actor" },
1286
- ],
1287
- });
1288
- } catch (error) {
1289
- console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
1290
- response.render("actor", {
1291
- title: "Actor",
1292
- actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
1293
- items: [],
1294
- actorUrl,
1295
- isFollowing,
1296
- canFollow,
1297
- baseUrl: request.baseUrl,
1298
- readerBaseUrl: request.baseUrl,
1299
- activeView: "channels",
1300
- error: "Could not fetch this actor's profile. They may have restricted access.",
1301
- breadcrumbs: [
1302
- { text: "Reader", href: request.baseUrl },
1303
- { text: "Actor" },
1304
- ],
1305
- });
1306
- }
1307
- }
1308
-
1309
- export async function followActorAction(request, response) {
1310
- const { actorUrl, actorName } = request.body;
1311
- if (!actorUrl) {
1312
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
1313
- }
1314
-
1315
- const apPlugin = getApPlugin(request);
1316
- if (!apPlugin) {
1317
- console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
1318
- return response.redirect(
1319
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1320
- );
1321
- }
1322
-
1323
- const result = await apPlugin.followActor(actorUrl, { name: actorName });
1324
- if (!result.ok) {
1325
- console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
1326
- }
1327
-
1328
- return response.redirect(
1329
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1330
- );
1331
- }
1332
-
1333
- export async function unfollowActorAction(request, response) {
1334
- const { actorUrl } = request.body;
1335
- if (!actorUrl) {
1336
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
1337
- }
1338
-
1339
- const apPlugin = getApPlugin(request);
1340
- if (!apPlugin) {
1341
- console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
1342
- return response.redirect(
1343
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1344
- );
1345
- }
1346
-
1347
- const result = await apPlugin.unfollowActor(actorUrl);
1348
- if (!result.ok) {
1349
- console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
1350
- }
1351
-
1352
- return response.redirect(
1353
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1354
- );
1355
- }
1356
-
1357
- /**
1358
- * Timeline view - all channels chronologically
1359
- * @param {object} request - Express request
1360
- * @param {object} response - Express response
1361
- */
1362
- export async function timeline(request, response) {
1363
- const { application } = request.app.locals;
1364
- const userId = getUserId(request);
1365
- const { before, after } = request.query;
1366
-
1367
- // Get channels with colors for filtering UI and item decoration
1368
- const channelList = await getChannelsWithColors(application, userId);
1369
-
1370
- // Build channel lookup map (ObjectId string -> { name, color, uid })
1371
- const channelMap = new Map();
1372
- for (const ch of channelList) {
1373
- channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
1374
- }
1375
-
1376
- // Parse excluded channel IDs from query params
1377
- const excludeParam = request.query.exclude;
1378
- const excludeIds = excludeParam
1379
- ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
1380
- : [];
1381
-
1382
- // Exclude the notifications channel by default
1383
- const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
1384
- const excludeChannelIds = [...excludeIds];
1385
- if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
1386
- excludeChannelIds.push(notificationsChannel._id.toString());
1387
- }
1388
-
1389
- const result = await getAllTimelineItems(application, {
1390
- before,
1391
- after,
1392
- userId,
1393
- excludeChannelIds,
1394
- });
1395
-
1396
- // Proxy images
1397
- const proxyBaseUrl = application.url;
1398
- if (proxyBaseUrl && result.items) {
1399
- result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
1400
- }
1401
-
1402
- // Decorate items with channel name and color
1403
- for (const item of result.items) {
1404
- if (item._channelId) {
1405
- const info = channelMap.get(item._channelId);
1406
- if (info) {
1407
- item._channelName = info.name;
1408
- item._channelColor = info.color;
1409
- item._channelUid = info.uid;
1410
- }
1411
- }
1412
- }
1413
-
1414
- // Set view preference cookie
1415
- if (request.session) request.session.microsubView = "timeline";
1416
-
1417
- response.render("timeline", {
1418
- title: "Timeline",
1419
- channels: channelList,
1420
- items: result.items,
1421
- paging: result.paging,
1422
- excludeIds,
1423
- baseUrl: request.baseUrl,
1424
- readerBaseUrl: request.baseUrl,
1425
- activeView: "timeline",
1426
- breadcrumbs: [
1427
- { text: "Reader", href: request.baseUrl },
1428
- { text: "Timeline" },
1429
- ],
1430
- });
1431
- }
1432
-
1433
- /**
1434
- * Deck view - TweetDeck-style columns
1435
- * @param {object} request - Express request
1436
- * @param {object} response - Express response
1437
- */
1438
- export async function deck(request, response) {
1439
- const { application } = request.app.locals;
1440
- const userId = getUserId(request);
1441
-
1442
- const channelList = await getChannelsWithColors(application, userId);
1443
- const deckConfig = await getDeckConfig(application, userId);
1444
-
1445
- // Determine which channels to show as columns
1446
- let columnChannels;
1447
- if (deckConfig?.columns?.length > 0) {
1448
- // Use saved config order
1449
- const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
1450
- columnChannels = deckConfig.columns
1451
- .map((col) => channelMap.get(col.channelId.toString()))
1452
- .filter(Boolean);
1453
- } else {
1454
- // Default: all channels except notifications
1455
- columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
1456
- }
1457
-
1458
- // Fetch items for each column (limited to 10 per column for performance)
1459
- const proxyBaseUrl = application.url;
1460
- const columns = await Promise.all(
1461
- columnChannels.map(async (channel) => {
1462
- const result = await getTimelineItems(application, channel._id, {
1463
- userId,
1464
- limit: 10,
1465
- });
1466
-
1467
- if (proxyBaseUrl && result.items) {
1468
- result.items = result.items.map((item) =>
1469
- proxyItemImages(item, proxyBaseUrl),
1470
- );
1471
- }
1472
-
1473
- return {
1474
- channel,
1475
- items: result.items,
1476
- paging: result.paging,
1477
- };
1478
- }),
1479
- );
1480
-
1481
- // Set view preference cookie
1482
- if (request.session) request.session.microsubView = "deck";
1483
-
1484
- response.render("deck", {
1485
- title: "Deck",
1486
- columns,
1487
- baseUrl: request.baseUrl,
1488
- readerBaseUrl: request.baseUrl,
1489
- activeView: "deck",
1490
- breadcrumbs: [
1491
- { text: "Reader", href: request.baseUrl },
1492
- { text: "Deck" },
1493
- ],
1494
- });
1495
- }
1496
-
1497
- /**
1498
- * Deck settings page
1499
- * @param {object} request - Express request
1500
- * @param {object} response - Express response
1501
- */
1502
- export async function deckSettings(request, response) {
1503
- const { application } = request.app.locals;
1504
- const userId = getUserId(request);
1505
-
1506
- const channelList = await getChannelsWithColors(application, userId);
1507
- const deckConfig = await getDeckConfig(application, userId);
1508
-
1509
- const selectedIds = deckConfig?.columns
1510
- ? deckConfig.columns.map((col) => col.channelId.toString())
1511
- : channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
1512
-
1513
- response.render("deck-settings", {
1514
- title: "Deck settings",
1515
- channels: channelList,
1516
- selectedIds,
1517
- baseUrl: request.baseUrl,
1518
- readerBaseUrl: request.baseUrl,
1519
- activeView: "deck",
1520
- breadcrumbs: [
1521
- { text: "Reader", href: request.baseUrl },
1522
- { text: "Deck", href: `${request.baseUrl}/deck` },
1523
- { text: "Settings" },
1524
- ],
1525
- });
1526
- }
1527
-
1528
- /**
1529
- * Save deck settings
1530
- * @param {object} request - Express request
1531
- * @param {object} response - Express response
1532
- */
1533
- export async function saveDeckSettings(request, response) {
1534
- const { application } = request.app.locals;
1535
- const userId = getUserId(request);
1536
-
1537
- let { columns } = request.body;
1538
- if (!columns) columns = [];
1539
- if (!Array.isArray(columns)) columns = [columns];
1540
-
1541
- await saveDeckConfig(application, userId, columns);
1542
-
1543
- response.redirect(`${request.baseUrl}/deck`);
1544
- }
1545
-
1546
- export const readerController = {
1547
- index,
1548
- channels,
1549
- newChannel,
1550
- createChannel: createChannelAction,
1551
- channel,
1552
- channelHtml,
1553
- settings,
1554
- updateSettings,
1555
- markAllRead,
1556
- markViewRead,
1557
- deleteChannel: deleteChannelAction,
1558
- feeds,
1559
- addFeed,
1560
- removeFeed,
1561
- feedDetails,
1562
- editFeedForm,
1563
- updateFeedUrl,
1564
- rediscoverFeed,
1565
- refreshFeed,
1566
- item,
1567
- compose,
1568
- submitCompose,
1569
- searchPage,
1570
- searchFeeds,
1571
- subscribe,
1572
- actorProfile,
1573
- followActorAction,
1574
- unfollowActorAction,
1575
- timeline,
1576
- timelineHtml,
1577
- deck,
1578
- deckSettings,
1579
- saveDeckSettings,
1580
- };