@rmdes/indiekit-endpoint-microsub 1.0.0-beta.1

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 (52) hide show
  1. package/README.md +111 -0
  2. package/index.js +140 -0
  3. package/lib/cache/redis.js +133 -0
  4. package/lib/controllers/block.js +85 -0
  5. package/lib/controllers/channels.js +135 -0
  6. package/lib/controllers/events.js +56 -0
  7. package/lib/controllers/follow.js +108 -0
  8. package/lib/controllers/microsub.js +138 -0
  9. package/lib/controllers/mute.js +124 -0
  10. package/lib/controllers/preview.js +67 -0
  11. package/lib/controllers/reader.js +218 -0
  12. package/lib/controllers/search.js +142 -0
  13. package/lib/controllers/timeline.js +117 -0
  14. package/lib/feeds/atom.js +61 -0
  15. package/lib/feeds/fetcher.js +205 -0
  16. package/lib/feeds/hfeed.js +177 -0
  17. package/lib/feeds/jsonfeed.js +43 -0
  18. package/lib/feeds/normalizer.js +586 -0
  19. package/lib/feeds/parser.js +124 -0
  20. package/lib/feeds/rss.js +61 -0
  21. package/lib/polling/processor.js +201 -0
  22. package/lib/polling/scheduler.js +128 -0
  23. package/lib/polling/tier.js +139 -0
  24. package/lib/realtime/broker.js +241 -0
  25. package/lib/search/indexer.js +90 -0
  26. package/lib/search/query.js +197 -0
  27. package/lib/storage/channels.js +281 -0
  28. package/lib/storage/feeds.js +286 -0
  29. package/lib/storage/filters.js +265 -0
  30. package/lib/storage/items.js +419 -0
  31. package/lib/storage/read-state.js +109 -0
  32. package/lib/utils/jf2.js +170 -0
  33. package/lib/utils/pagination.js +157 -0
  34. package/lib/utils/validation.js +217 -0
  35. package/lib/webmention/processor.js +214 -0
  36. package/lib/webmention/receiver.js +54 -0
  37. package/lib/webmention/verifier.js +308 -0
  38. package/lib/websub/discovery.js +129 -0
  39. package/lib/websub/handler.js +163 -0
  40. package/lib/websub/subscriber.js +181 -0
  41. package/locales/en.json +80 -0
  42. package/package.json +54 -0
  43. package/views/channel-new.njk +33 -0
  44. package/views/channel.njk +41 -0
  45. package/views/compose.njk +61 -0
  46. package/views/item.njk +85 -0
  47. package/views/partials/actions.njk +15 -0
  48. package/views/partials/author.njk +17 -0
  49. package/views/partials/item-card.njk +65 -0
  50. package/views/partials/timeline.njk +10 -0
  51. package/views/reader.njk +37 -0
  52. package/views/settings.njk +81 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Channel storage operations
3
+ * @module storage/channels
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ import { generateChannelUid } from "../utils/jf2.js";
9
+
10
+ /**
11
+ * Get channels collection from application
12
+ * @param {object} application - Indiekit application
13
+ * @returns {object} MongoDB collection
14
+ */
15
+ function getCollection(application) {
16
+ return application.collections.get("microsub_channels");
17
+ }
18
+
19
+ /**
20
+ * Get items collection for unread counts
21
+ * @param {object} application - Indiekit application
22
+ * @returns {object} MongoDB collection
23
+ */
24
+ function getItemsCollection(application) {
25
+ return application.collections.get("microsub_items");
26
+ }
27
+
28
+ /**
29
+ * Create a new channel
30
+ * @param {object} application - Indiekit application
31
+ * @param {object} data - Channel data
32
+ * @param {string} data.name - Channel name
33
+ * @param {string} [data.userId] - User ID
34
+ * @returns {Promise<object>} Created channel
35
+ */
36
+ export async function createChannel(application, { name, userId }) {
37
+ const collection = getCollection(application);
38
+
39
+ // Generate unique UID with retry on collision
40
+ let uid;
41
+ let attempts = 0;
42
+ const maxAttempts = 5;
43
+
44
+ while (attempts < maxAttempts) {
45
+ uid = generateChannelUid();
46
+ const existing = await collection.findOne({ uid });
47
+ if (!existing) break;
48
+ attempts++;
49
+ }
50
+
51
+ if (attempts >= maxAttempts) {
52
+ throw new Error("Failed to generate unique channel UID");
53
+ }
54
+
55
+ // Get max order for user
56
+ const maxOrderResult = await collection
57
+ .find({ userId })
58
+ .toSorted({ order: -1 })
59
+ .limit(1)
60
+ .toArray();
61
+
62
+ const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0;
63
+
64
+ const channel = {
65
+ uid,
66
+ name,
67
+ userId,
68
+ order,
69
+ settings: {
70
+ excludeTypes: [],
71
+ excludeRegex: undefined,
72
+ },
73
+ createdAt: new Date(),
74
+ updatedAt: new Date(),
75
+ };
76
+
77
+ await collection.insertOne(channel);
78
+
79
+ return channel;
80
+ }
81
+
82
+ /**
83
+ * Get all channels for a user
84
+ * @param {object} application - Indiekit application
85
+ * @param {string} [userId] - User ID (optional for single-user mode)
86
+ * @returns {Promise<Array>} Array of channels with unread counts
87
+ */
88
+ export async function getChannels(application, userId) {
89
+ const collection = getCollection(application);
90
+ const itemsCollection = getItemsCollection(application);
91
+
92
+ const filter = userId ? { userId } : {};
93
+ const channels = await collection
94
+ // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
95
+ .find(filter)
96
+ .toSorted({ order: 1 })
97
+ .toArray();
98
+
99
+ // Get unread counts for each channel
100
+ const channelsWithCounts = await Promise.all(
101
+ channels.map(async (channel) => {
102
+ const unreadCount = await itemsCollection.countDocuments({
103
+ channelId: channel._id,
104
+ readBy: { $ne: userId },
105
+ });
106
+
107
+ return {
108
+ uid: channel.uid,
109
+ name: channel.name,
110
+ unread: unreadCount > 0 ? unreadCount : false,
111
+ };
112
+ }),
113
+ );
114
+
115
+ // Always include notifications channel first
116
+ const notificationsChannel = channelsWithCounts.find(
117
+ (c) => c.uid === "notifications",
118
+ );
119
+ const otherChannels = channelsWithCounts.filter(
120
+ (c) => c.uid !== "notifications",
121
+ );
122
+
123
+ if (notificationsChannel) {
124
+ return [notificationsChannel, ...otherChannels];
125
+ }
126
+
127
+ return channelsWithCounts;
128
+ }
129
+
130
+ /**
131
+ * Get a single channel by UID
132
+ * @param {object} application - Indiekit application
133
+ * @param {string} uid - Channel UID
134
+ * @param {string} [userId] - User ID
135
+ * @returns {Promise<object|null>} Channel or null
136
+ */
137
+ export async function getChannel(application, uid, userId) {
138
+ const collection = getCollection(application);
139
+ const query = { uid };
140
+ if (userId) query.userId = userId;
141
+
142
+ return collection.findOne(query);
143
+ }
144
+
145
+ /**
146
+ * Get channel by MongoDB ObjectId
147
+ * @param {object} application - Indiekit application
148
+ * @param {ObjectId|string} id - Channel ObjectId
149
+ * @returns {Promise<object|null>} Channel or null
150
+ */
151
+ export async function getChannelById(application, id) {
152
+ const collection = getCollection(application);
153
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
154
+ return collection.findOne({ _id: objectId });
155
+ }
156
+
157
+ /**
158
+ * Update a channel
159
+ * @param {object} application - Indiekit application
160
+ * @param {string} uid - Channel UID
161
+ * @param {object} updates - Fields to update
162
+ * @param {string} [userId] - User ID
163
+ * @returns {Promise<object|null>} Updated channel
164
+ */
165
+ export async function updateChannel(application, uid, updates, userId) {
166
+ const collection = getCollection(application);
167
+ const query = { uid };
168
+ if (userId) query.userId = userId;
169
+
170
+ const result = await collection.findOneAndUpdate(
171
+ query,
172
+ {
173
+ $set: {
174
+ ...updates,
175
+ updatedAt: new Date(),
176
+ },
177
+ },
178
+ { returnDocument: "after" },
179
+ );
180
+
181
+ return result;
182
+ }
183
+
184
+ /**
185
+ * Delete a channel
186
+ * @param {object} application - Indiekit application
187
+ * @param {string} uid - Channel UID
188
+ * @param {string} [userId] - User ID
189
+ * @returns {Promise<boolean>} True if deleted
190
+ */
191
+ export async function deleteChannel(application, uid, userId) {
192
+ const collection = getCollection(application);
193
+ const query = { uid };
194
+ if (userId) query.userId = userId;
195
+
196
+ // Don't allow deleting notifications channel
197
+ if (uid === "notifications") {
198
+ return false;
199
+ }
200
+
201
+ const result = await collection.deleteOne(query);
202
+ return result.deletedCount > 0;
203
+ }
204
+
205
+ /**
206
+ * Reorder channels
207
+ * @param {object} application - Indiekit application
208
+ * @param {Array} channelUids - Ordered array of channel UIDs
209
+ * @param {string} [userId] - User ID
210
+ * @returns {Promise<void>}
211
+ */
212
+ export async function reorderChannels(application, channelUids, userId) {
213
+ const collection = getCollection(application);
214
+
215
+ // Update order for each channel
216
+ const operations = channelUids.map((uid, index) => ({
217
+ updateOne: {
218
+ filter: userId ? { uid, userId } : { uid },
219
+ update: { $set: { order: index, updatedAt: new Date() } },
220
+ },
221
+ }));
222
+
223
+ if (operations.length > 0) {
224
+ await collection.bulkWrite(operations);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Update channel settings
230
+ * @param {object} application - Indiekit application
231
+ * @param {string} uid - Channel UID
232
+ * @param {object} settings - Settings to update
233
+ * @param {Array} [settings.excludeTypes] - Types to exclude
234
+ * @param {string} [settings.excludeRegex] - Regex pattern to exclude
235
+ * @param {string} [userId] - User ID
236
+ * @returns {Promise<object|null>} Updated channel
237
+ */
238
+ export async function updateChannelSettings(
239
+ application,
240
+ uid,
241
+ settings,
242
+ userId,
243
+ ) {
244
+ return updateChannel(application, uid, { settings }, userId);
245
+ }
246
+
247
+ /**
248
+ * Ensure notifications channel exists
249
+ * @param {object} application - Indiekit application
250
+ * @param {string} [userId] - User ID
251
+ * @returns {Promise<object>} Notifications channel
252
+ */
253
+ export async function ensureNotificationsChannel(application, userId) {
254
+ const collection = getCollection(application);
255
+
256
+ const existing = await collection.findOne({
257
+ uid: "notifications",
258
+ ...(userId && { userId }),
259
+ });
260
+
261
+ if (existing) {
262
+ return existing;
263
+ }
264
+
265
+ // Create notifications channel
266
+ const channel = {
267
+ uid: "notifications",
268
+ name: "Notifications",
269
+ userId,
270
+ order: -1, // Always first
271
+ settings: {
272
+ excludeTypes: [],
273
+ excludeRegex: undefined,
274
+ },
275
+ createdAt: new Date(),
276
+ updatedAt: new Date(),
277
+ };
278
+
279
+ await collection.insertOne(channel);
280
+ return channel;
281
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Feed subscription storage operations
3
+ * @module storage/feeds
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ /**
9
+ * Get feeds collection from application
10
+ * @param {object} application - Indiekit application
11
+ * @returns {object} MongoDB collection
12
+ */
13
+ function getCollection(application) {
14
+ return application.collections.get("microsub_feeds");
15
+ }
16
+
17
+ /**
18
+ * Create a new feed subscription
19
+ * @param {object} application - Indiekit application
20
+ * @param {object} data - Feed data
21
+ * @param {ObjectId} data.channelId - Channel ObjectId
22
+ * @param {string} data.url - Feed URL
23
+ * @param {string} [data.title] - Feed title
24
+ * @param {string} [data.photo] - Feed icon URL
25
+ * @returns {Promise<object>} Created feed
26
+ */
27
+ export async function createFeed(
28
+ application,
29
+ { channelId, url, title, photo },
30
+ ) {
31
+ const collection = getCollection(application);
32
+
33
+ // Check if feed already exists in channel
34
+ const existing = await collection.findOne({ channelId, url });
35
+ if (existing) {
36
+ return existing;
37
+ }
38
+
39
+ const feed = {
40
+ channelId,
41
+ url,
42
+ title: title || undefined,
43
+ photo: photo || undefined,
44
+ tier: 1, // Start at tier 1 (2 minutes)
45
+ unmodified: 0,
46
+ nextFetchAt: new Date(), // Fetch immediately
47
+ lastFetchedAt: undefined,
48
+ websub: undefined, // Will be populated if hub is discovered
49
+ createdAt: new Date(),
50
+ updatedAt: new Date(),
51
+ };
52
+
53
+ await collection.insertOne(feed);
54
+ return feed;
55
+ }
56
+
57
+ /**
58
+ * Get all feeds for a channel
59
+ * @param {object} application - Indiekit application
60
+ * @param {ObjectId|string} channelId - Channel ObjectId
61
+ * @returns {Promise<Array>} Array of feeds
62
+ */
63
+ export async function getFeedsForChannel(application, channelId) {
64
+ const collection = getCollection(application);
65
+ const objectId =
66
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
67
+
68
+ return collection.find({ channelId: objectId }).toArray();
69
+ }
70
+
71
+ /**
72
+ * Get a feed by URL and channel
73
+ * @param {object} application - Indiekit application
74
+ * @param {ObjectId|string} channelId - Channel ObjectId
75
+ * @param {string} url - Feed URL
76
+ * @returns {Promise<object|null>} Feed or null
77
+ */
78
+ export async function getFeedByUrl(application, channelId, url) {
79
+ const collection = getCollection(application);
80
+ const objectId =
81
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
82
+
83
+ return collection.findOne({ channelId: objectId, url });
84
+ }
85
+
86
+ /**
87
+ * Get a feed by ID
88
+ * @param {object} application - Indiekit application
89
+ * @param {ObjectId|string} id - Feed ObjectId
90
+ * @returns {Promise<object|null>} Feed or null
91
+ */
92
+ export async function getFeedById(application, id) {
93
+ const collection = getCollection(application);
94
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
95
+
96
+ return collection.findOne({ _id: objectId });
97
+ }
98
+
99
+ /**
100
+ * Update a feed
101
+ * @param {object} application - Indiekit application
102
+ * @param {ObjectId|string} id - Feed ObjectId
103
+ * @param {object} updates - Fields to update
104
+ * @returns {Promise<object|null>} Updated feed
105
+ */
106
+ export async function updateFeed(application, id, updates) {
107
+ const collection = getCollection(application);
108
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
109
+
110
+ const result = await collection.findOneAndUpdate(
111
+ { _id: objectId },
112
+ {
113
+ $set: {
114
+ ...updates,
115
+ updatedAt: new Date(),
116
+ },
117
+ },
118
+ { returnDocument: "after" },
119
+ );
120
+
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Delete a feed subscription
126
+ * @param {object} application - Indiekit application
127
+ * @param {ObjectId|string} channelId - Channel ObjectId
128
+ * @param {string} url - Feed URL
129
+ * @returns {Promise<boolean>} True if deleted
130
+ */
131
+ export async function deleteFeed(application, channelId, url) {
132
+ const collection = getCollection(application);
133
+ const objectId =
134
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
135
+
136
+ const result = await collection.deleteOne({ channelId: objectId, url });
137
+ return result.deletedCount > 0;
138
+ }
139
+
140
+ /**
141
+ * Delete all feeds for a channel
142
+ * @param {object} application - Indiekit application
143
+ * @param {ObjectId|string} channelId - Channel ObjectId
144
+ * @returns {Promise<number>} Number of deleted feeds
145
+ */
146
+ export async function deleteFeedsForChannel(application, channelId) {
147
+ const collection = getCollection(application);
148
+ const objectId =
149
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
150
+
151
+ const result = await collection.deleteMany({ channelId: objectId });
152
+ return result.deletedCount;
153
+ }
154
+
155
+ /**
156
+ * Get feeds ready for polling
157
+ * @param {object} application - Indiekit application
158
+ * @returns {Promise<Array>} Array of feeds to fetch
159
+ */
160
+ export async function getFeedsToFetch(application) {
161
+ const collection = getCollection(application);
162
+ const now = new Date();
163
+
164
+ return collection
165
+ .find({
166
+ $or: [{ nextFetchAt: undefined }, { nextFetchAt: { $lte: now } }],
167
+ })
168
+ .toArray();
169
+ }
170
+
171
+ /**
172
+ * Update feed after fetch
173
+ * @param {object} application - Indiekit application
174
+ * @param {ObjectId|string} id - Feed ObjectId
175
+ * @param {boolean} changed - Whether content changed
176
+ * @param {object} [extra] - Additional fields to update
177
+ * @returns {Promise<object|null>} Updated feed
178
+ */
179
+ export async function updateFeedAfterFetch(
180
+ application,
181
+ id,
182
+ changed,
183
+ extra = {},
184
+ ) {
185
+ const collection = getCollection(application);
186
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
187
+
188
+ // If extra contains tier info, use that (from processor)
189
+ // Otherwise calculate locally (legacy behavior)
190
+ let updateData;
191
+
192
+ if (extra.tier === undefined) {
193
+ // Get current feed state for legacy calculation
194
+ const feed = await collection.findOne({ _id: objectId });
195
+ if (!feed) return;
196
+
197
+ let tier = feed.tier;
198
+ let unmodified = feed.unmodified;
199
+
200
+ if (changed) {
201
+ tier = Math.max(0, tier - 1);
202
+ unmodified = 0;
203
+ } else {
204
+ unmodified++;
205
+ if (unmodified >= 2) {
206
+ tier = Math.min(10, tier + 1);
207
+ unmodified = 0;
208
+ }
209
+ }
210
+
211
+ const minutes = Math.ceil(Math.pow(2, tier));
212
+ const nextFetchAt = new Date(Date.now() + minutes * 60 * 1000);
213
+
214
+ updateData = {
215
+ tier,
216
+ unmodified,
217
+ nextFetchAt,
218
+ lastFetchedAt: new Date(),
219
+ updatedAt: new Date(),
220
+ };
221
+ } else {
222
+ updateData = {
223
+ ...extra,
224
+ lastFetchedAt: new Date(),
225
+ updatedAt: new Date(),
226
+ };
227
+ }
228
+
229
+ return collection.findOneAndUpdate(
230
+ { _id: objectId },
231
+ { $set: updateData },
232
+ { returnDocument: "after" },
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Update feed WebSub subscription
238
+ * @param {object} application - Indiekit application
239
+ * @param {ObjectId|string} id - Feed ObjectId
240
+ * @param {object} websub - WebSub data
241
+ * @param {string} websub.hub - Hub URL
242
+ * @param {string} [websub.topic] - Feed topic URL
243
+ * @param {string} [websub.secret] - Subscription secret
244
+ * @param {number} [websub.leaseSeconds] - Lease duration
245
+ * @returns {Promise<object|null>} Updated feed
246
+ */
247
+ export async function updateFeedWebsub(application, id, websub) {
248
+ const collection = getCollection(application);
249
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
250
+
251
+ const websubData = {
252
+ hub: websub.hub,
253
+ topic: websub.topic,
254
+ };
255
+
256
+ // Only set these if provided (subscription confirmed)
257
+ if (websub.secret) {
258
+ websubData.secret = websub.secret;
259
+ }
260
+ if (websub.leaseSeconds) {
261
+ websubData.leaseSeconds = websub.leaseSeconds;
262
+ websubData.expiresAt = new Date(Date.now() + websub.leaseSeconds * 1000);
263
+ }
264
+
265
+ return collection.findOneAndUpdate(
266
+ { _id: objectId },
267
+ {
268
+ $set: {
269
+ websub: websubData,
270
+ updatedAt: new Date(),
271
+ },
272
+ },
273
+ { returnDocument: "after" },
274
+ );
275
+ }
276
+
277
+ /**
278
+ * Get feed by WebSub subscription ID
279
+ * Used for WebSub callback handling
280
+ * @param {object} application - Indiekit application
281
+ * @param {string} subscriptionId - Subscription ID (feed ObjectId as string)
282
+ * @returns {Promise<object|null>} Feed or null
283
+ */
284
+ export async function getFeedBySubscriptionId(application, subscriptionId) {
285
+ return getFeedById(application, subscriptionId);
286
+ }