@rmdes/indiekit-endpoint-microsub 1.0.63 → 1.0.65
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/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/channel.js +0 -1
- 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/scheduler.js +4 -14
- 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-read-state.js +30 -59
- package/lib/storage/items-retention.js +1 -3
- package/lib/storage/items.js +0 -14
- package/lib/utils/blogroll-notify.js +3 -3
- package/lib/utils/constants.js +7 -0
- package/lib/utils/jf2.js +0 -109
- package/lib/utils/pagination.js +4 -11
- package/lib/utils/sanitize.js +1 -2
- package/lib/utils/validation.js +0 -10
- package/lib/webmention/processor.js +2 -95
- package/lib/websub/handler.js +2 -2
- package/lib/websub/subscriber.js +0 -17
- package/locales/en.json +3 -27
- package/package.json +1 -4
- package/lib/search/indexer.js +0 -90
- package/lib/storage/items-search.js +0 -34
- 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
|
-
}
|
|
@@ -5,22 +5,17 @@
|
|
|
5
5
|
|
|
6
6
|
import { ObjectId } from "mongodb";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
MAX_FULL_READ_ITEMS,
|
|
10
|
+
UNREAD_RETENTION_DAYS,
|
|
11
|
+
} from "../utils/constants.js";
|
|
9
12
|
import { getCollection } from "./items.js";
|
|
10
13
|
|
|
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
14
|
/**
|
|
17
15
|
* Cleanup old read items by stripping content but preserving dedup skeletons.
|
|
18
|
-
*
|
|
16
|
+
* Prevents the vicious cycle where deleted read items get re-ingested as
|
|
19
17
|
* unread by the poller because the dedup record (channelId + uid) was destroyed.
|
|
20
18
|
*
|
|
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
19
|
* @param {object} collection - MongoDB collection
|
|
25
20
|
* @param {ObjectId} channelObjectId - Channel ObjectId
|
|
26
21
|
* @param {string} userId - User ID
|
|
@@ -32,7 +27,6 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
|
32
27
|
});
|
|
33
28
|
|
|
34
29
|
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
35
|
-
// Find old read items beyond the retention limit
|
|
36
30
|
const itemsToCleanup = await collection
|
|
37
31
|
.find({
|
|
38
32
|
channelId: channelObjectId,
|
|
@@ -41,59 +35,36 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
|
41
35
|
})
|
|
42
36
|
.sort({ published: -1, _id: -1 })
|
|
43
37
|
.skip(MAX_FULL_READ_ITEMS)
|
|
44
|
-
.project({ _id: 1
|
|
38
|
+
.project({ _id: 1 })
|
|
45
39
|
.toArray();
|
|
46
40
|
|
|
47
41
|
if (itemsToCleanup.length === 0) return;
|
|
48
42
|
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
},
|
|
43
|
+
const ids = itemsToCleanup.map((item) => item._id);
|
|
44
|
+
const stripped = await collection.updateMany(
|
|
45
|
+
{ _id: { $in: ids } },
|
|
46
|
+
{
|
|
47
|
+
$set: { _stripped: true },
|
|
48
|
+
$unset: {
|
|
49
|
+
name: "",
|
|
50
|
+
content: "",
|
|
51
|
+
summary: "",
|
|
52
|
+
author: "",
|
|
53
|
+
category: "",
|
|
54
|
+
photo: "",
|
|
55
|
+
video: "",
|
|
56
|
+
audio: "",
|
|
57
|
+
likeOf: "",
|
|
58
|
+
repostOf: "",
|
|
59
|
+
bookmarkOf: "",
|
|
60
|
+
inReplyTo: "",
|
|
61
|
+
source: "",
|
|
91
62
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
console.info(
|
|
66
|
+
`[Microsub] Stripped ${stripped.modifiedCount} old read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
|
|
67
|
+
);
|
|
97
68
|
}
|
|
98
69
|
}
|
|
99
70
|
|
|
@@ -3,11 +3,9 @@
|
|
|
3
3
|
* @module storage/items-retention
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { MAX_FULL_READ_ITEMS } from "../utils/constants.js";
|
|
6
7
|
import { getCollection } from "./items.js";
|
|
7
8
|
|
|
8
|
-
// Maximum number of full read items to keep per channel before stripping content.
|
|
9
|
-
const MAX_FULL_READ_ITEMS = 200;
|
|
10
|
-
|
|
11
9
|
// Global retention defaults. Each can be overridden per channel via
|
|
12
10
|
// channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}. The "notifications"
|
|
13
11
|
// channel is exempt from these caps entirely — webmentions are high-signal and
|
package/lib/storage/items.js
CHANGED
|
@@ -323,20 +323,6 @@ export async function getItemById(application, id, userId) {
|
|
|
323
323
|
return transformToJf2(item, userId);
|
|
324
324
|
}
|
|
325
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));
|
|
338
|
-
}
|
|
339
|
-
|
|
340
326
|
/**
|
|
341
327
|
* Remove items from channel
|
|
342
328
|
* @param {object} application - Indiekit application
|
|
@@ -36,7 +36,7 @@ export async function notifyBlogroll(application, action, data) {
|
|
|
36
36
|
status: "deleted",
|
|
37
37
|
});
|
|
38
38
|
if (deleted) {
|
|
39
|
-
console.
|
|
39
|
+
console.info(
|
|
40
40
|
`[Microsub→Blogroll] Skipping follow for ${data.url} — previously deleted by user`,
|
|
41
41
|
);
|
|
42
42
|
return;
|
|
@@ -76,7 +76,7 @@ export async function notifyBlogroll(application, action, data) {
|
|
|
76
76
|
{ upsert: true },
|
|
77
77
|
);
|
|
78
78
|
|
|
79
|
-
console.
|
|
79
|
+
console.info(`[Microsub→Blogroll] Added/updated feed ${data.url}`);
|
|
80
80
|
} else if (action === "unfollow") {
|
|
81
81
|
// Soft-delete the blog entry if it came from microsub
|
|
82
82
|
const result = await collection.updateOne(
|
|
@@ -96,7 +96,7 @@ export async function notifyBlogroll(application, action, data) {
|
|
|
96
96
|
);
|
|
97
97
|
|
|
98
98
|
if (result.modifiedCount > 0) {
|
|
99
|
-
console.
|
|
99
|
+
console.info(`[Microsub→Blogroll] Soft-deleted feed ${data.url}`);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
}
|
package/lib/utils/constants.js
CHANGED
|
@@ -5,3 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
/** Retention period for unread count queries (only count recent items) */
|
|
7
7
|
export const UNREAD_RETENTION_DAYS = 30;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Maximum number of full read items to keep per channel/user before stripping
|
|
11
|
+
* content. Items beyond this limit are converted to lightweight dedup skeletons
|
|
12
|
+
* (channelId, uid, readBy) so the poller doesn't re-ingest them as new unread.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_FULL_READ_ITEMS = 200;
|
package/lib/utils/jf2.js
CHANGED
|
@@ -29,115 +29,6 @@ export function generateChannelUid() {
|
|
|
29
29
|
return result;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
* Create a jf2 Item from normalized feed data
|
|
34
|
-
* @param {object} data - Normalized item data
|
|
35
|
-
* @param {object} source - Feed source metadata
|
|
36
|
-
* @returns {object} jf2 Item object
|
|
37
|
-
*/
|
|
38
|
-
export function createJf2Item(data, source) {
|
|
39
|
-
return {
|
|
40
|
-
type: "entry",
|
|
41
|
-
uid: data.uid,
|
|
42
|
-
url: data.url,
|
|
43
|
-
name: data.name || undefined,
|
|
44
|
-
content: data.content || undefined,
|
|
45
|
-
summary: data.summary || undefined,
|
|
46
|
-
published: data.published,
|
|
47
|
-
updated: data.updated || undefined,
|
|
48
|
-
author: data.author || undefined,
|
|
49
|
-
category: data.category || [],
|
|
50
|
-
photo: data.photo || [],
|
|
51
|
-
video: data.video || [],
|
|
52
|
-
audio: data.audio || [],
|
|
53
|
-
// Interaction types
|
|
54
|
-
"like-of": data.likeOf || [],
|
|
55
|
-
"repost-of": data.repostOf || [],
|
|
56
|
-
"bookmark-of": data.bookmarkOf || [],
|
|
57
|
-
"in-reply-to": data.inReplyTo || [],
|
|
58
|
-
// Internal properties (prefixed with _)
|
|
59
|
-
_id: data._id,
|
|
60
|
-
_is_read: data._is_read || false,
|
|
61
|
-
_source: source,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Create a jf2 Card (author/person)
|
|
67
|
-
* @param {object} data - Author data
|
|
68
|
-
* @returns {object} jf2 Card object
|
|
69
|
-
*/
|
|
70
|
-
export function createJf2Card(data) {
|
|
71
|
-
if (!data) return;
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
type: "card",
|
|
75
|
-
name: data.name || undefined,
|
|
76
|
-
url: data.url || undefined,
|
|
77
|
-
photo: data.photo || undefined,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Create a jf2 Content object
|
|
83
|
-
* @param {string} text - Plain text content
|
|
84
|
-
* @param {string} html - HTML content
|
|
85
|
-
* @returns {object|undefined} jf2 Content object
|
|
86
|
-
*/
|
|
87
|
-
export function createJf2Content(text, html) {
|
|
88
|
-
if (!text && !html) return;
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
text: text || stripHtml(html),
|
|
92
|
-
html: html || undefined,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Strip HTML tags from string
|
|
98
|
-
* @param {string} html - HTML string
|
|
99
|
-
* @returns {string} Plain text
|
|
100
|
-
*/
|
|
101
|
-
export function stripHtml(html) {
|
|
102
|
-
if (!html) return "";
|
|
103
|
-
return html.replaceAll(/<[^>]*>/g, "").trim();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Create a jf2 Feed response
|
|
108
|
-
* @param {object} options - Feed options
|
|
109
|
-
* @param {Array} options.items - Array of jf2 items
|
|
110
|
-
* @param {object} options.paging - Pagination cursors
|
|
111
|
-
* @returns {object} jf2 Feed object
|
|
112
|
-
*/
|
|
113
|
-
export function createJf2Feed({ items, paging }) {
|
|
114
|
-
const feed = {
|
|
115
|
-
items: items || [],
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
if (paging) {
|
|
119
|
-
feed.paging = {};
|
|
120
|
-
if (paging.before) feed.paging.before = paging.before;
|
|
121
|
-
if (paging.after) feed.paging.after = paging.after;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return feed;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Create a Channel response object
|
|
129
|
-
* @param {object} channel - Channel data
|
|
130
|
-
* @param {number} unreadCount - Number of unread items
|
|
131
|
-
* @returns {object} Channel object for API response
|
|
132
|
-
*/
|
|
133
|
-
export function createChannelResponse(channel, unreadCount = 0) {
|
|
134
|
-
return {
|
|
135
|
-
uid: channel.uid,
|
|
136
|
-
name: channel.name,
|
|
137
|
-
unread: unreadCount > 0 ? unreadCount : false,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
32
|
/**
|
|
142
33
|
* Create a Feed response object
|
|
143
34
|
* @param {object} feed - Feed data
|
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/sanitize.js
CHANGED
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
|
*/
|