@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.
- package/README.md +111 -0
- package/index.js +140 -0
- package/lib/cache/redis.js +133 -0
- package/lib/controllers/block.js +85 -0
- package/lib/controllers/channels.js +135 -0
- package/lib/controllers/events.js +56 -0
- package/lib/controllers/follow.js +108 -0
- package/lib/controllers/microsub.js +138 -0
- package/lib/controllers/mute.js +124 -0
- package/lib/controllers/preview.js +67 -0
- package/lib/controllers/reader.js +218 -0
- package/lib/controllers/search.js +142 -0
- package/lib/controllers/timeline.js +117 -0
- package/lib/feeds/atom.js +61 -0
- package/lib/feeds/fetcher.js +205 -0
- package/lib/feeds/hfeed.js +177 -0
- package/lib/feeds/jsonfeed.js +43 -0
- package/lib/feeds/normalizer.js +586 -0
- package/lib/feeds/parser.js +124 -0
- package/lib/feeds/rss.js +61 -0
- package/lib/polling/processor.js +201 -0
- package/lib/polling/scheduler.js +128 -0
- package/lib/polling/tier.js +139 -0
- package/lib/realtime/broker.js +241 -0
- package/lib/search/indexer.js +90 -0
- package/lib/search/query.js +197 -0
- package/lib/storage/channels.js +281 -0
- package/lib/storage/feeds.js +286 -0
- package/lib/storage/filters.js +265 -0
- package/lib/storage/items.js +419 -0
- package/lib/storage/read-state.js +109 -0
- package/lib/utils/jf2.js +170 -0
- package/lib/utils/pagination.js +157 -0
- package/lib/utils/validation.js +217 -0
- package/lib/webmention/processor.js +214 -0
- package/lib/webmention/receiver.js +54 -0
- package/lib/webmention/verifier.js +308 -0
- package/lib/websub/discovery.js +129 -0
- package/lib/websub/handler.js +163 -0
- package/lib/websub/subscriber.js +181 -0
- package/locales/en.json +80 -0
- package/package.json +54 -0
- package/views/channel-new.njk +33 -0
- package/views/channel.njk +41 -0
- package/views/compose.njk +61 -0
- package/views/item.njk +85 -0
- package/views/partials/actions.njk +15 -0
- package/views/partials/author.njk +17 -0
- package/views/partials/item-card.njk +65 -0
- package/views/partials/timeline.njk +10 -0
- package/views/reader.njk +37 -0
- 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
|
+
}
|