@rmdes/indiekit-endpoint-microsub 1.0.64 → 1.0.66
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/assets/styles.css +8 -0
- package/lib/cache/redis.js +0 -31
- package/lib/controllers/block.js +0 -2
- package/lib/controllers/channels.js +0 -2
- package/lib/controllers/events.js +0 -2
- package/lib/controllers/follow.js +0 -2
- package/lib/controllers/mute.js +0 -2
- package/lib/controllers/preview.js +0 -2
- package/lib/controllers/reader/index.js +2 -38
- package/lib/controllers/search.js +0 -2
- package/lib/controllers/timeline.js +0 -2
- package/lib/feeds/discovery.js +1 -1
- package/lib/feeds/normalizer.js +2 -2
- package/lib/media/proxy.js +5 -5
- package/lib/polling/processor.js +5 -0
- package/lib/polling/scheduler.js +0 -10
- package/lib/polling/tier.js +6 -51
- package/lib/realtime/broker.js +3 -75
- package/lib/storage/channels.js +1 -1
- package/lib/storage/feeds.js +1 -17
- package/lib/storage/filters.js +26 -197
- package/lib/storage/items.js +72 -15
- package/lib/utils/pagination.js +4 -11
- package/lib/utils/validation.js +0 -10
- package/lib/webmention/processor.js +0 -93
- package/lib/websub/subscriber.js +0 -17
- package/package.json +1 -4
- package/views/partials/item-card-compact.njk +4 -1
- package/views/partials/item-card.njk +10 -8
- package/lib/storage/read-state.js +0 -109
- package/lib/websub/discovery.js +0 -129
package/lib/storage/filters.js
CHANGED
|
@@ -1,117 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Item filtering: exclude-types and exclude-regex predicates applied during
|
|
3
|
+
* feed ingestion. Used by `lib/polling/processor.js` when deciding whether a
|
|
4
|
+
* newly-parsed item should be stored.
|
|
5
|
+
*
|
|
6
|
+
* Historical note: this module previously also held mute/block storage
|
|
7
|
+
* (microsub_muted / microsub_blocked operations). That code path was abandoned
|
|
8
|
+
* — those collections are managed directly by `lib/controllers/mute.js` and
|
|
9
|
+
* `lib/controllers/block.js` against MongoDB without an intermediate storage
|
|
10
|
+
* helper layer. Only the per-channel exclude filters remain here.
|
|
11
|
+
*
|
|
3
12
|
* @module storage/filters
|
|
4
13
|
*/
|
|
5
14
|
|
|
6
|
-
import { ObjectId } from "mongodb";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Get muted collection
|
|
10
|
-
* @param {object} application - Indiekit application
|
|
11
|
-
* @returns {object} MongoDB collection
|
|
12
|
-
*/
|
|
13
|
-
function getMutedCollection(application) {
|
|
14
|
-
return application.collections.get("microsub_muted");
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Get blocked collection
|
|
19
|
-
* @param {object} application - Indiekit application
|
|
20
|
-
* @returns {object} MongoDB collection
|
|
21
|
-
*/
|
|
22
|
-
function getBlockedCollection(application) {
|
|
23
|
-
return application.collections.get("microsub_blocked");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Check if a URL is muted for a user/channel
|
|
28
|
-
* @param {object} application - Indiekit application
|
|
29
|
-
* @param {string} userId - User ID
|
|
30
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
31
|
-
* @param {string} url - URL to check
|
|
32
|
-
* @returns {Promise<boolean>} Whether the URL is muted
|
|
33
|
-
*/
|
|
34
|
-
export async function isMuted(application, userId, channelId, url) {
|
|
35
|
-
const collection = getMutedCollection(application);
|
|
36
|
-
const channelObjectId =
|
|
37
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
38
|
-
|
|
39
|
-
// Check for channel-specific mute
|
|
40
|
-
const channelMute = await collection.findOne({
|
|
41
|
-
userId,
|
|
42
|
-
channelId: channelObjectId,
|
|
43
|
-
url,
|
|
44
|
-
});
|
|
45
|
-
if (channelMute) return true;
|
|
46
|
-
|
|
47
|
-
// Check for global mute (no channelId)
|
|
48
|
-
const globalMute = await collection.findOne({
|
|
49
|
-
userId,
|
|
50
|
-
channelId: { $exists: false },
|
|
51
|
-
url,
|
|
52
|
-
});
|
|
53
|
-
return !!globalMute;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Check if a URL is blocked for a user
|
|
58
|
-
* @param {object} application - Indiekit application
|
|
59
|
-
* @param {string} userId - User ID
|
|
60
|
-
* @param {string} url - URL to check
|
|
61
|
-
* @returns {Promise<boolean>} Whether the URL is blocked
|
|
62
|
-
*/
|
|
63
|
-
export async function isBlocked(application, userId, url) {
|
|
64
|
-
const collection = getBlockedCollection(application);
|
|
65
|
-
const blocked = await collection.findOne({ userId, url });
|
|
66
|
-
return !!blocked;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Check if an item passes all filters
|
|
71
|
-
* @param {object} application - Indiekit application
|
|
72
|
-
* @param {string} userId - User ID
|
|
73
|
-
* @param {object} channel - Channel document with settings
|
|
74
|
-
* @param {object} item - Feed item to check
|
|
75
|
-
* @returns {Promise<boolean>} Whether the item passes all filters
|
|
76
|
-
*/
|
|
77
|
-
export async function passesAllFilters(application, userId, channel, item) {
|
|
78
|
-
// Check if author URL is blocked
|
|
79
|
-
if (
|
|
80
|
-
item.author?.url &&
|
|
81
|
-
(await isBlocked(application, userId, item.author.url))
|
|
82
|
-
) {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Check if source URL is muted
|
|
87
|
-
if (
|
|
88
|
-
item._source?.url &&
|
|
89
|
-
(await isMuted(application, userId, channel._id, item._source.url))
|
|
90
|
-
) {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Check channel settings filters
|
|
95
|
-
if (channel?.settings) {
|
|
96
|
-
// Check excludeTypes
|
|
97
|
-
if (!passesTypeFilter(item, channel.settings)) {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Check excludeRegex
|
|
102
|
-
if (!passesRegexFilter(item, channel.settings)) {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
15
|
/**
|
|
111
|
-
* Check if an item passes the excludeTypes filter
|
|
16
|
+
* Check if an item passes the channel.settings.excludeTypes filter.
|
|
112
17
|
* @param {object} item - Feed item
|
|
113
18
|
* @param {object} settings - Channel settings
|
|
114
|
-
* @returns {boolean}
|
|
19
|
+
* @returns {boolean} True when the item should be kept
|
|
115
20
|
*/
|
|
116
21
|
export function passesTypeFilter(item, settings) {
|
|
117
22
|
if (!settings.excludeTypes || settings.excludeTypes.length === 0) {
|
|
@@ -123,10 +28,10 @@ export function passesTypeFilter(item, settings) {
|
|
|
123
28
|
}
|
|
124
29
|
|
|
125
30
|
/**
|
|
126
|
-
* Check if an item passes the excludeRegex filter
|
|
31
|
+
* Check if an item passes the channel.settings.excludeRegex filter.
|
|
127
32
|
* @param {object} item - Feed item
|
|
128
33
|
* @param {object} settings - Channel settings
|
|
129
|
-
* @returns {boolean}
|
|
34
|
+
* @returns {boolean} True when the item should be kept
|
|
130
35
|
*/
|
|
131
36
|
export function passesRegexFilter(item, settings) {
|
|
132
37
|
if (!settings.excludeRegex) {
|
|
@@ -146,17 +51,26 @@ export function passesRegexFilter(item, settings) {
|
|
|
146
51
|
|
|
147
52
|
return !regex.test(searchText);
|
|
148
53
|
} catch {
|
|
149
|
-
// Invalid regex
|
|
54
|
+
// Invalid regex — skip the filter rather than rejecting every item.
|
|
150
55
|
return true;
|
|
151
56
|
}
|
|
152
57
|
}
|
|
153
58
|
|
|
154
59
|
/**
|
|
155
|
-
*
|
|
60
|
+
* Classify an item by its interaction property. Internal helper for
|
|
61
|
+
* passesTypeFilter — only its symbolic return value (one of "like" | "repost"
|
|
62
|
+
* | "bookmark" | "reply" | "rsvp" | "checkin" | "post") is compared against
|
|
63
|
+
* the excludeTypes list.
|
|
64
|
+
*
|
|
65
|
+
* Note: a similar but stricter classifier exists in `lib/utils/jf2.js` for
|
|
66
|
+
* API-response shaping. The two cannot trivially be merged because this one
|
|
67
|
+
* treats kebab-case keys only and emits a "post" default that the jf2 one
|
|
68
|
+
* doesn't.
|
|
69
|
+
*
|
|
156
70
|
* @param {object} item - Feed item
|
|
157
71
|
* @returns {string} Interaction type
|
|
158
72
|
*/
|
|
159
|
-
|
|
73
|
+
function detectInteractionType(item) {
|
|
160
74
|
if (item["like-of"] && item["like-of"].length > 0) {
|
|
161
75
|
return "like";
|
|
162
76
|
}
|
|
@@ -178,88 +92,3 @@ export function detectInteractionType(item) {
|
|
|
178
92
|
|
|
179
93
|
return "post";
|
|
180
94
|
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Get all muted URLs for a user/channel
|
|
184
|
-
* @param {object} application - Indiekit application
|
|
185
|
-
* @param {string} userId - User ID
|
|
186
|
-
* @param {ObjectId|string} [channelId] - Channel ObjectId (optional, for channel-specific)
|
|
187
|
-
* @returns {Promise<Array>} Array of muted URLs
|
|
188
|
-
*/
|
|
189
|
-
export async function getMutedUrls(application, userId, channelId) {
|
|
190
|
-
const collection = getMutedCollection(application);
|
|
191
|
-
const filter = { userId };
|
|
192
|
-
|
|
193
|
-
if (channelId) {
|
|
194
|
-
const channelObjectId =
|
|
195
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
196
|
-
filter.channelId = channelObjectId;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
|
|
200
|
-
const muted = await collection.find(filter).toArray();
|
|
201
|
-
return muted.map((m) => m.url);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Get all blocked URLs for a user
|
|
206
|
-
* @param {object} application - Indiekit application
|
|
207
|
-
* @param {string} userId - User ID
|
|
208
|
-
* @returns {Promise<Array>} Array of blocked URLs
|
|
209
|
-
*/
|
|
210
|
-
export async function getBlockedUrls(application, userId) {
|
|
211
|
-
const collection = getBlockedCollection(application);
|
|
212
|
-
const blocked = await collection.find({ userId }).toArray();
|
|
213
|
-
return blocked.map((b) => b.url);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Update channel filter settings
|
|
218
|
-
* @param {object} application - Indiekit application
|
|
219
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
220
|
-
* @param {object} filters - Filter settings to update
|
|
221
|
-
* @param {Array} [filters.excludeTypes] - Post types to exclude
|
|
222
|
-
* @param {string} [filters.excludeRegex] - Regex pattern to exclude
|
|
223
|
-
* @returns {Promise<object>} Updated channel
|
|
224
|
-
*/
|
|
225
|
-
export async function updateChannelFilters(application, channelId, filters) {
|
|
226
|
-
const collection = application.collections.get("microsub_channels");
|
|
227
|
-
const channelObjectId =
|
|
228
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
229
|
-
|
|
230
|
-
const updateFields = {};
|
|
231
|
-
|
|
232
|
-
if (filters.excludeTypes !== undefined) {
|
|
233
|
-
updateFields["settings.excludeTypes"] = filters.excludeTypes;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (filters.excludeRegex !== undefined) {
|
|
237
|
-
updateFields["settings.excludeRegex"] = filters.excludeRegex;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const result = await collection.findOneAndUpdate(
|
|
241
|
-
{ _id: channelObjectId },
|
|
242
|
-
{ $set: updateFields },
|
|
243
|
-
{ returnDocument: "after" },
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
return result;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Create indexes for filter collections
|
|
251
|
-
* @param {object} application - Indiekit application
|
|
252
|
-
* @returns {Promise<void>}
|
|
253
|
-
*/
|
|
254
|
-
export async function createFilterIndexes(application) {
|
|
255
|
-
const mutedCollection = getMutedCollection(application);
|
|
256
|
-
const blockedCollection = getBlockedCollection(application);
|
|
257
|
-
|
|
258
|
-
// Muted collection indexes
|
|
259
|
-
await mutedCollection.createIndex({ userId: 1, channelId: 1, url: 1 });
|
|
260
|
-
await mutedCollection.createIndex({ userId: 1 });
|
|
261
|
-
|
|
262
|
-
// Blocked collection indexes
|
|
263
|
-
await blockedCollection.createIndex({ userId: 1, url: 1 }, { unique: true });
|
|
264
|
-
await blockedCollection.createIndex({ userId: 1 });
|
|
265
|
-
}
|
package/lib/storage/items.js
CHANGED
|
@@ -121,6 +121,73 @@ export function transformToJf2(item, userId) {
|
|
|
121
121
|
return jf2;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Derive a favicon URL from a site or feed URL
|
|
126
|
+
* @param {string} url - Site or feed URL
|
|
127
|
+
* @returns {string|undefined} Favicon URL at the site origin
|
|
128
|
+
*/
|
|
129
|
+
function faviconUrl(url) {
|
|
130
|
+
try {
|
|
131
|
+
return new URL(url).origin + "/favicon.ico";
|
|
132
|
+
} catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Enrich jf2 items with source identity (name, photo, url) from their feeds.
|
|
139
|
+
* Read-time join against microsub_feeds so existing items pick up feed
|
|
140
|
+
* metadata (title, channel image, site URL) without a migration, and stay
|
|
141
|
+
* fresh when a feed's metadata improves. Falls back to the site favicon
|
|
142
|
+
* when the feed has no image.
|
|
143
|
+
* @param {object} application - Indiekit application
|
|
144
|
+
* @param {Array<object>} items - jf2 items (mutated in place)
|
|
145
|
+
* @returns {Promise<Array<object>>} The same items, enriched
|
|
146
|
+
*/
|
|
147
|
+
export async function enrichItemsWithFeedSource(application, items) {
|
|
148
|
+
const feedIds = [
|
|
149
|
+
...new Set(items.map((item) => item._feedId).filter(Boolean)),
|
|
150
|
+
];
|
|
151
|
+
if (feedIds.length === 0) {
|
|
152
|
+
return items;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const feeds = await application.collections
|
|
156
|
+
.get("microsub_feeds")
|
|
157
|
+
.find({ _id: { $in: feedIds.map((id) => new ObjectId(id)) } })
|
|
158
|
+
.project({ title: 1, photo: 1, url: 1, siteUrl: 1 })
|
|
159
|
+
.toArray();
|
|
160
|
+
const feedsById = new Map(feeds.map((feed) => [feed._id.toString(), feed]));
|
|
161
|
+
|
|
162
|
+
for (const item of items) {
|
|
163
|
+
const feed = feedsById.get(item._feedId);
|
|
164
|
+
if (!feed) continue;
|
|
165
|
+
|
|
166
|
+
const siteUrl = feed.siteUrl || feed.url;
|
|
167
|
+
item._source = {
|
|
168
|
+
...item._source,
|
|
169
|
+
name: feed.title || item._source?.name || safeHostname(siteUrl),
|
|
170
|
+
photo: feed.photo || faviconUrl(siteUrl),
|
|
171
|
+
url: siteUrl,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return items;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract hostname from a URL, or return the input on parse failure
|
|
180
|
+
* @param {string} url - URL string
|
|
181
|
+
* @returns {string} Hostname
|
|
182
|
+
*/
|
|
183
|
+
function safeHostname(url) {
|
|
184
|
+
try {
|
|
185
|
+
return new URL(url).hostname;
|
|
186
|
+
} catch {
|
|
187
|
+
return url;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
124
191
|
/**
|
|
125
192
|
* Add an item to a channel
|
|
126
193
|
* @param {object} application - Indiekit application
|
|
@@ -218,6 +285,7 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
|
|
218
285
|
|
|
219
286
|
// Transform to jf2 format
|
|
220
287
|
const jf2Items = items.map((item) => transformToJf2(item, options.userId));
|
|
288
|
+
await enrichItemsWithFeedSource(application, jf2Items);
|
|
221
289
|
|
|
222
290
|
// Generate paging cursors
|
|
223
291
|
const paging = generatePagingCursors(items, limit, hasMore, options.before);
|
|
@@ -282,6 +350,7 @@ export async function getAllTimelineItems(application, options = {}) {
|
|
|
282
350
|
}
|
|
283
351
|
|
|
284
352
|
const jf2Items = items.map((item) => transformToJf2(item, options.userId));
|
|
353
|
+
await enrichItemsWithFeedSource(application, jf2Items);
|
|
285
354
|
|
|
286
355
|
const paging = generatePagingCursors(items, limit, hasMore, options.before);
|
|
287
356
|
|
|
@@ -320,21 +389,9 @@ export async function getItemById(application, id, userId) {
|
|
|
320
389
|
return;
|
|
321
390
|
}
|
|
322
391
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Get items by UIDs
|
|
328
|
-
* @param {object} application - Indiekit application
|
|
329
|
-
* @param {Array} uids - Array of item UIDs
|
|
330
|
-
* @param {string} [userId] - User ID for read state
|
|
331
|
-
* @returns {Promise<Array>} Array of jf2 items
|
|
332
|
-
*/
|
|
333
|
-
export async function getItemsByUids(application, uids, userId) {
|
|
334
|
-
const collection = getCollection(application);
|
|
335
|
-
|
|
336
|
-
const items = await collection.find({ uid: { $in: uids } }).toArray();
|
|
337
|
-
return items.map((item) => transformToJf2(item, userId));
|
|
392
|
+
const jf2 = transformToJf2(item, userId);
|
|
393
|
+
await enrichItemsWithFeedSource(application, [jf2]);
|
|
394
|
+
return jf2;
|
|
338
395
|
}
|
|
339
396
|
|
|
340
397
|
/**
|
package/lib/utils/pagination.js
CHANGED
|
@@ -11,7 +11,7 @@ import { ObjectId } from "mongodb";
|
|
|
11
11
|
* @param {string} id - Item ID
|
|
12
12
|
* @returns {string} Base64-encoded cursor
|
|
13
13
|
*/
|
|
14
|
-
|
|
14
|
+
function encodeCursor(timestamp, id) {
|
|
15
15
|
const data = {
|
|
16
16
|
t: timestamp instanceof Date ? timestamp.toISOString() : timestamp,
|
|
17
17
|
i: id.toString(),
|
|
@@ -24,7 +24,7 @@ export function encodeCursor(timestamp, id) {
|
|
|
24
24
|
* @param {string} cursor - Base64-encoded cursor
|
|
25
25
|
* @returns {object|null} Decoded cursor with timestamp and id
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
function decodeCursor(cursor) {
|
|
28
28
|
if (!cursor) return;
|
|
29
29
|
|
|
30
30
|
try {
|
|
@@ -133,15 +133,8 @@ export function generatePagingCursors(items, limit, hasMore, before) {
|
|
|
133
133
|
return paging;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*/
|
|
139
|
-
export const DEFAULT_LIMIT = 20;
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Maximum pagination limit
|
|
143
|
-
*/
|
|
144
|
-
export const MAX_LIMIT = 100;
|
|
136
|
+
const DEFAULT_LIMIT = 20;
|
|
137
|
+
const MAX_LIMIT = 100;
|
|
145
138
|
|
|
146
139
|
/**
|
|
147
140
|
* Parse and validate limit parameter
|
package/lib/utils/validation.js
CHANGED
|
@@ -23,16 +23,6 @@ export const VALID_ACTIONS = [
|
|
|
23
23
|
"events",
|
|
24
24
|
];
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* Valid channel methods
|
|
28
|
-
*/
|
|
29
|
-
export const VALID_CHANNEL_METHODS = ["delete", "order"];
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Valid timeline methods
|
|
33
|
-
*/
|
|
34
|
-
export const VALID_TIMELINE_METHODS = ["mark_read", "mark_read_source", "mark_unread", "remove"];
|
|
35
|
-
|
|
36
26
|
/**
|
|
37
27
|
* Valid exclude types for channel filtering
|
|
38
28
|
*/
|
|
@@ -98,87 +98,6 @@ export async function processWebmention(application, source, target, userId) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
102
|
-
* Delete a webmention (when source no longer links to target)
|
|
103
|
-
* @param {object} application - Indiekit application
|
|
104
|
-
* @param {string} source - Source URL
|
|
105
|
-
* @param {string} target - Target URL
|
|
106
|
-
* @returns {Promise<boolean>} Whether deletion was successful
|
|
107
|
-
*/
|
|
108
|
-
export async function deleteWebmention(application, source, target) {
|
|
109
|
-
const collection = getCollection(application);
|
|
110
|
-
const result = await collection.deleteOne({ source, target });
|
|
111
|
-
return result.deletedCount > 0;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Get notifications for a user
|
|
116
|
-
* @param {object} application - Indiekit application
|
|
117
|
-
* @param {string} userId - User ID
|
|
118
|
-
* @param {object} options - Query options
|
|
119
|
-
* @returns {Promise<Array>} Array of notifications
|
|
120
|
-
*/
|
|
121
|
-
export async function getNotifications(application, userId, options = {}) {
|
|
122
|
-
const collection = getCollection(application);
|
|
123
|
-
const { limit = 20, unreadOnly = false } = options;
|
|
124
|
-
|
|
125
|
-
const query = { userId };
|
|
126
|
-
if (unreadOnly) {
|
|
127
|
-
query.readBy = { $ne: userId };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/* eslint-disable unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB cursor methods */
|
|
131
|
-
const notifications = await collection
|
|
132
|
-
.find(query)
|
|
133
|
-
.sort({ published: -1 })
|
|
134
|
-
.limit(limit)
|
|
135
|
-
.toArray();
|
|
136
|
-
/* eslint-enable unicorn/no-array-callback-reference, unicorn/no-array-sort */
|
|
137
|
-
|
|
138
|
-
return notifications.map((n) => transformNotification(n, userId));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Mark notifications as read
|
|
143
|
-
* @param {object} application - Indiekit application
|
|
144
|
-
* @param {string} userId - User ID
|
|
145
|
-
* @param {Array} ids - Notification IDs to mark as read
|
|
146
|
-
* @returns {Promise<number>} Number of notifications updated
|
|
147
|
-
*/
|
|
148
|
-
export async function markNotificationsRead(application, userId, ids) {
|
|
149
|
-
const collection = getCollection(application);
|
|
150
|
-
const { ObjectId } = await import("mongodb");
|
|
151
|
-
|
|
152
|
-
const objectIds = ids.map((id) => {
|
|
153
|
-
try {
|
|
154
|
-
return new ObjectId(id);
|
|
155
|
-
} catch {
|
|
156
|
-
return id;
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const result = await collection.updateMany(
|
|
161
|
-
{ _id: { $in: objectIds } },
|
|
162
|
-
{ $addToSet: { readBy: userId } },
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
return result.modifiedCount;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Get unread notification count
|
|
170
|
-
* @param {object} application - Indiekit application
|
|
171
|
-
* @param {string} userId - User ID
|
|
172
|
-
* @returns {Promise<number>} Unread count
|
|
173
|
-
*/
|
|
174
|
-
export async function getUnreadNotificationCount(application, userId) {
|
|
175
|
-
const collection = getCollection(application);
|
|
176
|
-
return collection.countDocuments({
|
|
177
|
-
userId,
|
|
178
|
-
readBy: { $ne: userId },
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
101
|
/**
|
|
183
102
|
* Transform notification to API format
|
|
184
103
|
* @param {object} notification - Database notification
|
|
@@ -200,15 +119,3 @@ function transformNotification(notification, userId) {
|
|
|
200
119
|
};
|
|
201
120
|
}
|
|
202
121
|
|
|
203
|
-
/**
|
|
204
|
-
* Create indexes for notifications
|
|
205
|
-
* @param {object} application - Indiekit application
|
|
206
|
-
* @returns {Promise<void>}
|
|
207
|
-
*/
|
|
208
|
-
export async function createNotificationIndexes(application) {
|
|
209
|
-
const collection = getCollection(application);
|
|
210
|
-
|
|
211
|
-
await collection.createIndex({ userId: 1, published: -1 });
|
|
212
|
-
await collection.createIndex({ source: 1, target: 1 });
|
|
213
|
-
await collection.createIndex({ userId: 1, readBy: 1 });
|
|
214
|
-
}
|
package/lib/websub/subscriber.js
CHANGED
|
@@ -165,23 +165,6 @@ export function verifySignature(signature, body, secret) {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
* Check if a WebSub subscription is about to expire
|
|
170
|
-
* @param {object} feed - Feed document
|
|
171
|
-
* @param {number} [thresholdSeconds] - Seconds before expiry to consider "expiring"
|
|
172
|
-
* @returns {boolean} Whether subscription is expiring soon
|
|
173
|
-
*/
|
|
174
|
-
export function isSubscriptionExpiring(feed, thresholdSeconds = 86_400) {
|
|
175
|
-
if (!feed.websub?.expiresAt) {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const expiresAt = new Date(feed.websub.expiresAt);
|
|
180
|
-
const threshold = new Date(Date.now() + thresholdSeconds * 1000);
|
|
181
|
-
|
|
182
|
-
return expiresAt <= threshold;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
168
|
/**
|
|
186
169
|
* Get callback URL for a feed
|
|
187
170
|
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.66",
|
|
4
4
|
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -40,12 +40,9 @@
|
|
|
40
40
|
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
|
41
41
|
"@indiekit/frontend": "^1.0.0-beta.25",
|
|
42
42
|
"@indiekit/util": "^1.0.0-beta.25",
|
|
43
|
-
"debug": "^4.3.2",
|
|
44
43
|
"express": "^5.0.0",
|
|
45
44
|
"feedparser": "^2.2.10",
|
|
46
|
-
"htmlparser2": "^9.0.0",
|
|
47
45
|
"ioredis": "^5.3.0",
|
|
48
|
-
"luxon": "^3.4.0",
|
|
49
46
|
"microformats-parser": "^2.0.0",
|
|
50
47
|
"express-rate-limit": "^7.0.0",
|
|
51
48
|
"safe-regex2": "^4.0.0",
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
{% endif %}
|
|
19
19
|
<div class="ms-item-card-compact__meta">
|
|
20
20
|
{% if item._source %}
|
|
21
|
-
<span class="ms-item-card-compact__source">
|
|
21
|
+
<span class="ms-item-card-compact__source">
|
|
22
|
+
{%- if item._source.photo %}<img src="{{ item._source.photo }}" alt="" class="ms-item-card-compact__favicon" width="16" height="16" loading="lazy"> {% endif -%}
|
|
23
|
+
{{- item._source.name or item._source.url -}}
|
|
24
|
+
</span>
|
|
22
25
|
{% elif item.author %}
|
|
23
26
|
<span class="ms-item-card-compact__source">{{ item.author.name }}</span>
|
|
24
27
|
{% endif %}
|
|
@@ -50,25 +50,27 @@
|
|
|
50
50
|
{% endif %}
|
|
51
51
|
|
|
52
52
|
<a href="{{ baseUrl }}/item/{{ item._id }}" class="ms-item-card__link">
|
|
53
|
-
{# Author #}
|
|
54
|
-
{%
|
|
53
|
+
{# Author / source identity — prefer item author, fall back to feed source #}
|
|
54
|
+
{% set identityPhoto = item.author.photo or item._source.photo %}
|
|
55
|
+
{% set identityName = item.author.name or item._source.name %}
|
|
56
|
+
{% if identityName or identityPhoto %}
|
|
55
57
|
<div class="ms-item-card__author">
|
|
56
58
|
<div class="ms-item-card__avatar-wrap" data-avatar-fallback>
|
|
57
|
-
{% if
|
|
58
|
-
<img src="{{
|
|
59
|
+
{% if identityPhoto %}
|
|
60
|
+
<img src="{{ identityPhoto }}"
|
|
59
61
|
alt=""
|
|
60
62
|
class="ms-item-card__author-photo"
|
|
61
63
|
width="40"
|
|
62
64
|
height="40"
|
|
63
65
|
loading="lazy">
|
|
64
66
|
{% endif %}
|
|
65
|
-
<span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{
|
|
67
|
+
<span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ identityName[0] | upper if identityName else "?" }}</span>
|
|
66
68
|
</div>
|
|
67
69
|
<div class="ms-item-card__author-info">
|
|
68
|
-
<span class="ms-item-card__author-name">{{
|
|
69
|
-
{% if item._source %}
|
|
70
|
+
<span class="ms-item-card__author-name">{{ identityName or "Unknown" }}</span>
|
|
71
|
+
{% if item._source and (item._source.name or item._source.url) and item._source.name != identityName %}
|
|
70
72
|
<span class="ms-item-card__source">{{ item._source.name or item._source.url }}</span>
|
|
71
|
-
{% elif item.author.url %}
|
|
73
|
+
{% elif item.author.url and item.author.name %}
|
|
72
74
|
<span class="ms-item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
73
75
|
{% endif %}
|
|
74
76
|
</div>
|