@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,287 @@
1
+ /**
2
+ * Timeline item read state management
3
+ * @module storage/items-read-state
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ import { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
9
+ import { getCollection } from "./items.js";
10
+
11
+ // Maximum number of full read items to keep per channel before stripping content.
12
+ // Items beyond this limit are converted to lightweight dedup skeletons (channelId,
13
+ // uid, readBy) so the poller doesn't re-ingest them as new unread entries.
14
+ const MAX_FULL_READ_ITEMS = 200;
15
+
16
+ /**
17
+ * Cleanup old read items by stripping content but preserving dedup skeletons.
18
+ * This prevents the vicious cycle where deleted read items get re-ingested as
19
+ * unread by the poller because the dedup record (channelId + uid) was destroyed.
20
+ *
21
+ * AP items (feedId: null) are hard-deleted instead of stripped, since no poller
22
+ * re-ingests them — they arrive via inbox push and don't need dedup skeletons.
23
+ *
24
+ * @param {object} collection - MongoDB collection
25
+ * @param {ObjectId} channelObjectId - Channel ObjectId
26
+ * @param {string} userId - User ID
27
+ */
28
+ async function cleanupOldReadItems(collection, channelObjectId, userId) {
29
+ const readCount = await collection.countDocuments({
30
+ channelId: channelObjectId,
31
+ readBy: userId,
32
+ });
33
+
34
+ if (readCount > MAX_FULL_READ_ITEMS) {
35
+ // Find old read items beyond the retention limit
36
+ const itemsToCleanup = await collection
37
+ .find({
38
+ channelId: channelObjectId,
39
+ readBy: userId,
40
+ _stripped: { $ne: true },
41
+ })
42
+ .sort({ published: -1, _id: -1 })
43
+ .skip(MAX_FULL_READ_ITEMS)
44
+ .project({ _id: 1, feedId: 1 })
45
+ .toArray();
46
+
47
+ if (itemsToCleanup.length === 0) return;
48
+
49
+ // Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
50
+ const apItemIds = [];
51
+ const rssItemIds = [];
52
+ for (const item of itemsToCleanup) {
53
+ if (item.feedId) {
54
+ rssItemIds.push(item._id);
55
+ } else {
56
+ apItemIds.push(item._id);
57
+ }
58
+ }
59
+
60
+ // Hard-delete AP items — no poller to re-ingest, skeletons are useless
61
+ if (apItemIds.length > 0) {
62
+ const deleted = await collection.deleteMany({
63
+ _id: { $in: apItemIds },
64
+ });
65
+ console.info(
66
+ `[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
67
+ );
68
+ }
69
+
70
+ // Strip RSS items to dedup skeletons — poller would re-ingest if deleted
71
+ if (rssItemIds.length > 0) {
72
+ const stripped = await collection.updateMany(
73
+ { _id: { $in: rssItemIds } },
74
+ {
75
+ $set: { _stripped: true },
76
+ $unset: {
77
+ name: "",
78
+ content: "",
79
+ summary: "",
80
+ author: "",
81
+ category: "",
82
+ photo: "",
83
+ video: "",
84
+ audio: "",
85
+ likeOf: "",
86
+ repostOf: "",
87
+ bookmarkOf: "",
88
+ inReplyTo: "",
89
+ source: "",
90
+ },
91
+ },
92
+ );
93
+ console.info(
94
+ `[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Mark items as read
102
+ * @param {object} application - Indiekit application
103
+ * @param {ObjectId|string} channelId - Channel ObjectId
104
+ * @param {Array} entryIds - Array of entry IDs to mark as read (can be ObjectId, uid, or URL)
105
+ * @param {string} userId - User ID
106
+ * @returns {Promise<number>} Number of items updated
107
+ */
108
+ export async function markItemsRead(application, channelId, entryIds, userId) {
109
+ const collection = getCollection(application);
110
+ const channelObjectId =
111
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
112
+
113
+ console.info(
114
+ `[Microsub] markItemsRead called for channel ${channelId}, entries:`,
115
+ entryIds,
116
+ `userId: ${userId}`,
117
+ );
118
+
119
+ // Handle "last-read-entry" special value
120
+ if (entryIds.includes("last-read-entry")) {
121
+ // Mark all items in channel as read
122
+ const result = await collection.updateMany(
123
+ { channelId: channelObjectId },
124
+ { $addToSet: { readBy: userId } },
125
+ );
126
+ console.info(
127
+ `[Microsub] Marked all items as read: ${result.modifiedCount} updated`,
128
+ );
129
+
130
+ // Cleanup old read items, keeping only the most recent
131
+ await cleanupOldReadItems(collection, channelObjectId, userId);
132
+
133
+ return result.modifiedCount;
134
+ }
135
+
136
+ // Convert string IDs to ObjectIds where possible
137
+ const objectIds = entryIds
138
+ .map((id) => {
139
+ try {
140
+ return new ObjectId(id);
141
+ } catch {
142
+ return;
143
+ }
144
+ })
145
+ .filter(Boolean);
146
+
147
+ // Build query to match by _id, uid, or url (Microsub spec uses URLs as entry identifiers)
148
+ const result = await collection.updateMany(
149
+ {
150
+ channelId: channelObjectId,
151
+ $or: [
152
+ ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
153
+ { uid: { $in: entryIds } },
154
+ { url: { $in: entryIds } },
155
+ ],
156
+ },
157
+ { $addToSet: { readBy: userId } },
158
+ );
159
+
160
+ console.info(
161
+ `[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
162
+ );
163
+
164
+ return result.modifiedCount;
165
+ }
166
+
167
+ /**
168
+ * Mark all items from a specific feed as read in a channel
169
+ * @param {object} application - Indiekit application
170
+ * @param {ObjectId|string} channelId - Channel ObjectId
171
+ * @param {ObjectId|string} feedId - Feed ObjectId
172
+ * @param {string} userId - User ID
173
+ * @returns {Promise<number>} Number of items updated
174
+ */
175
+ export async function markFeedItemsRead(
176
+ application,
177
+ channelId,
178
+ feedId,
179
+ userId,
180
+ ) {
181
+ const collection = getCollection(application);
182
+ const channelObjectId =
183
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
184
+ const feedObjectId =
185
+ typeof feedId === "string" ? new ObjectId(feedId) : feedId;
186
+
187
+ const result = await collection.updateMany(
188
+ { channelId: channelObjectId, feedId: feedObjectId },
189
+ { $addToSet: { readBy: userId } },
190
+ );
191
+
192
+ console.info(
193
+ `[Microsub] markFeedItemsRead: marked ${result.modifiedCount} items from feed ${feedId} as read`,
194
+ );
195
+
196
+ // Cleanup old read items
197
+ await cleanupOldReadItems(collection, channelObjectId, userId);
198
+
199
+ return result.modifiedCount;
200
+ }
201
+
202
+ /**
203
+ * Mark items as unread
204
+ * @param {object} application - Indiekit application
205
+ * @param {ObjectId|string} channelId - Channel ObjectId
206
+ * @param {Array} entryIds - Array of entry IDs to mark as unread (can be ObjectId, uid, or URL)
207
+ * @param {string} userId - User ID
208
+ * @returns {Promise<number>} Number of items updated
209
+ */
210
+ export async function markItemsUnread(
211
+ application,
212
+ channelId,
213
+ entryIds,
214
+ userId,
215
+ ) {
216
+ const collection = getCollection(application);
217
+ const channelObjectId =
218
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
219
+
220
+ // Convert string IDs to ObjectIds where possible
221
+ const objectIds = entryIds
222
+ .map((id) => {
223
+ try {
224
+ return new ObjectId(id);
225
+ } catch {
226
+ return;
227
+ }
228
+ })
229
+ .filter(Boolean);
230
+
231
+ // Match by _id, uid, or url
232
+ const result = await collection.updateMany(
233
+ {
234
+ channelId: channelObjectId,
235
+ $or: [
236
+ ...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
237
+ { uid: { $in: entryIds } },
238
+ { url: { $in: entryIds } },
239
+ ],
240
+ },
241
+ { $pull: { readBy: userId } },
242
+ );
243
+
244
+ return result.modifiedCount;
245
+ }
246
+
247
+ /**
248
+ * Count read items in a channel
249
+ * @param {object} application - Indiekit application
250
+ * @param {ObjectId|string} channelId - Channel ObjectId
251
+ * @param {string} userId - User ID
252
+ * @returns {Promise<number>} Number of read items
253
+ */
254
+ export async function countReadItems(application, channelId, userId) {
255
+ const collection = getCollection(application);
256
+ const objectId =
257
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
258
+
259
+ return collection.countDocuments({
260
+ channelId: objectId,
261
+ readBy: userId,
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Get unread count for a channel
267
+ * @param {object} application - Indiekit application
268
+ * @param {ObjectId|string} channelId - Channel ObjectId
269
+ * @param {string} userId - User ID
270
+ * @returns {Promise<number>} Unread count
271
+ */
272
+ export async function getUnreadCount(application, channelId, userId) {
273
+ const collection = getCollection(application);
274
+ const objectId =
275
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
276
+
277
+ // Only count items from the last UNREAD_RETENTION_DAYS, exclude stripped skeletons
278
+ const cutoffDate = new Date();
279
+ cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
280
+
281
+ return collection.countDocuments({
282
+ channelId: objectId,
283
+ readBy: { $ne: userId },
284
+ published: { $gte: cutoffDate },
285
+ _stripped: { $ne: true },
286
+ });
287
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Timeline item cleanup and retention
3
+ * @module storage/items-retention
4
+ */
5
+
6
+ import { getCollection } from "./items.js";
7
+
8
+ // Maximum number of full read items to keep per channel before stripping content.
9
+ const MAX_FULL_READ_ITEMS = 200;
10
+
11
+ // Maximum age (in days) for stripped skeletons and unread items.
12
+ // After this period, both are hard-deleted to prevent unbounded growth.
13
+ const MAX_ITEM_AGE_DAYS = 30;
14
+
15
+ /**
16
+ * Cleanup all read items across all channels (startup cleanup).
17
+ * RSS items are stripped to dedup skeletons; AP items are hard-deleted.
18
+ * @param {object} application - Indiekit application
19
+ * @returns {Promise<number>} Total number of items cleaned up
20
+ */
21
+ export async function cleanupAllReadItems(application) {
22
+ const collection = getCollection(application);
23
+ const channelsCollection = application.collections.get("microsub_channels");
24
+
25
+ const channels = await channelsCollection.find({}).toArray();
26
+ let totalCleaned = 0;
27
+
28
+ for (const channel of channels) {
29
+ const readByUsers = await collection.distinct("readBy", {
30
+ channelId: channel._id,
31
+ readBy: { $exists: true, $ne: [] },
32
+ });
33
+
34
+ for (const userId of readByUsers) {
35
+ if (!userId) continue;
36
+
37
+ const readCount = await collection.countDocuments({
38
+ channelId: channel._id,
39
+ readBy: userId,
40
+ _stripped: { $ne: true },
41
+ });
42
+
43
+ if (readCount > MAX_FULL_READ_ITEMS) {
44
+ const itemsToCleanup = await collection
45
+ .find({
46
+ channelId: channel._id,
47
+ readBy: userId,
48
+ _stripped: { $ne: true },
49
+ })
50
+ .sort({ published: -1, _id: -1 })
51
+ .skip(MAX_FULL_READ_ITEMS)
52
+ .project({ _id: 1, feedId: 1 })
53
+ .toArray();
54
+
55
+ if (itemsToCleanup.length > 0) {
56
+ const apItemIds = [];
57
+ const rssItemIds = [];
58
+ for (const item of itemsToCleanup) {
59
+ if (item.feedId) {
60
+ rssItemIds.push(item._id);
61
+ } else {
62
+ apItemIds.push(item._id);
63
+ }
64
+ }
65
+
66
+ // Hard-delete AP items
67
+ if (apItemIds.length > 0) {
68
+ const deleted = await collection.deleteMany({
69
+ _id: { $in: apItemIds },
70
+ });
71
+ totalCleaned += deleted.deletedCount;
72
+ console.info(
73
+ `[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
74
+ );
75
+ }
76
+
77
+ // Strip RSS items to skeletons
78
+ if (rssItemIds.length > 0) {
79
+ const stripped = await collection.updateMany(
80
+ { _id: { $in: rssItemIds } },
81
+ {
82
+ $set: { _stripped: true },
83
+ $unset: {
84
+ name: "",
85
+ content: "",
86
+ summary: "",
87
+ author: "",
88
+ category: "",
89
+ photo: "",
90
+ video: "",
91
+ audio: "",
92
+ likeOf: "",
93
+ repostOf: "",
94
+ bookmarkOf: "",
95
+ inReplyTo: "",
96
+ source: "",
97
+ },
98
+ },
99
+ );
100
+ totalCleaned += stripped.modifiedCount;
101
+ console.info(
102
+ `[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
103
+ );
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ if (totalCleaned > 0) {
111
+ console.info(
112
+ `[Microsub] Startup cleanup complete: ${totalCleaned} total items cleaned`,
113
+ );
114
+ }
115
+
116
+ return totalCleaned;
117
+ }
118
+
119
+ /**
120
+ * Delete stale items: stripped skeletons and unread items older than MAX_ITEM_AGE_DAYS.
121
+ * Stripped skeletons have served their dedup purpose; stale unread items are unlikely
122
+ * to be read. Both are hard-deleted to prevent unbounded collection growth.
123
+ * @param {object} application - Indiekit application
124
+ * @returns {Promise<number>} Total number of items deleted
125
+ */
126
+ export async function cleanupStaleItems(application) {
127
+ const collection = getCollection(application);
128
+ const cutoff = new Date();
129
+ cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
130
+
131
+ // Delete stripped skeletons older than cutoff
132
+ const strippedResult = await collection.deleteMany({
133
+ _stripped: true,
134
+ $or: [
135
+ { published: { $lt: cutoff } },
136
+ { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
137
+ ],
138
+ });
139
+
140
+ // Delete unread items older than cutoff
141
+ const unreadResult = await collection.deleteMany({
142
+ readBy: { $in: [null, []] },
143
+ _stripped: { $ne: true },
144
+ $or: [
145
+ { published: { $lt: cutoff } },
146
+ { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
147
+ ],
148
+ });
149
+
150
+ // Also catch items with no readBy field at all
151
+ const noReadByResult = await collection.deleteMany({
152
+ readBy: { $exists: false },
153
+ _stripped: { $ne: true },
154
+ $or: [
155
+ { published: { $lt: cutoff } },
156
+ { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
157
+ ],
158
+ });
159
+
160
+ const total =
161
+ strippedResult.deletedCount +
162
+ unreadResult.deletedCount +
163
+ noReadByResult.deletedCount;
164
+
165
+ if (total > 0) {
166
+ console.info(
167
+ `[Microsub] Stale cleanup: deleted ${strippedResult.deletedCount} stripped skeletons, ` +
168
+ `${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
169
+ `(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
170
+ );
171
+ }
172
+
173
+ return total;
174
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Timeline item search
3
+ * @module storage/items-search
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ import { getCollection, transformToJf2 } from "./items.js";
9
+
10
+ /**
11
+ * Search items by text
12
+ * @param {object} application - Indiekit application
13
+ * @param {ObjectId|string} channelId - Channel ObjectId
14
+ * @param {string} query - Search query
15
+ * @param {number} [limit] - Max results
16
+ * @returns {Promise<Array>} Array of matching items
17
+ */
18
+ export async function searchItems(application, channelId, query, limit = 20) {
19
+ const collection = getCollection(application);
20
+ const objectId =
21
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
22
+
23
+ // Use MongoDB text index for efficient full-text search
24
+ const items = await collection
25
+ .find({
26
+ channelId: objectId,
27
+ $text: { $search: query },
28
+ })
29
+ .sort({ score: { $meta: "textScore" } })
30
+ .limit(limit)
31
+ .toArray();
32
+
33
+ return items.map((item) => transformToJf2(item));
34
+ }