@rmdes/indiekit-endpoint-microsub 1.0.61 → 1.0.64
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/index.js +5 -7
- package/lib/controllers/reader/channel.js +41 -3
- package/lib/controllers/reader/index.js +0 -15
- package/lib/polling/scheduler.js +4 -4
- package/lib/storage/channels.js +1 -37
- package/lib/storage/items-read-state.js +30 -59
- package/lib/storage/items-retention.js +208 -99
- 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/sanitize.js +1 -2
- package/lib/utils/validation.js +25 -0
- package/lib/webmention/processor.js +2 -2
- package/lib/websub/handler.js +2 -2
- package/locales/en.json +11 -27
- package/package.json +1 -1
- package/views/partials/item-card.njk +0 -6
- package/views/settings.njk +37 -0
- package/lib/activitypub/outbox-fetcher.js +0 -267
- package/lib/controllers/reader/actor.js +0 -142
- package/lib/search/indexer.js +0 -90
- package/lib/storage/items-search.js +0 -34
- package/views/actor.njk +0 -188
|
@@ -3,18 +3,21 @@
|
|
|
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
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const
|
|
9
|
+
// Global retention defaults. Each can be overridden per channel via
|
|
10
|
+
// channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}. The "notifications"
|
|
11
|
+
// channel is exempt from these caps entirely — webmentions are high-signal and
|
|
12
|
+
// users may want long history there.
|
|
13
|
+
export const DEFAULT_MAX_ITEMS = 1000;
|
|
14
|
+
export const DEFAULT_MAX_ITEMS_PER_FEED = 50;
|
|
15
|
+
export const DEFAULT_MAX_UNREAD_AGE_DAYS = 30;
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Cleanup all read items across all channels (startup cleanup).
|
|
17
|
-
*
|
|
19
|
+
* Read items beyond MAX_FULL_READ_ITEMS are stripped to skeletons (kept for
|
|
20
|
+
* dedup, content removed).
|
|
18
21
|
* @param {object} application - Indiekit application
|
|
19
22
|
* @returns {Promise<number>} Total number of items cleaned up
|
|
20
23
|
*/
|
|
@@ -49,59 +52,36 @@ export async function cleanupAllReadItems(application) {
|
|
|
49
52
|
})
|
|
50
53
|
.sort({ published: -1, _id: -1 })
|
|
51
54
|
.skip(MAX_FULL_READ_ITEMS)
|
|
52
|
-
.project({ _id: 1
|
|
55
|
+
.project({ _id: 1 })
|
|
53
56
|
.toArray();
|
|
54
57
|
|
|
55
58
|
if (itemsToCleanup.length > 0) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Strip RSS items to skeletons
|
|
78
|
-
if (rssItemIds.length > 0) {
|
|
79
|
-
const stripped = await collection.updateMany(
|
|
80
|
-
{ _id: { $in: rssItemIds } },
|
|
81
|
-
{
|
|
82
|
-
$set: { _stripped: true },
|
|
83
|
-
$unset: {
|
|
84
|
-
name: "",
|
|
85
|
-
content: "",
|
|
86
|
-
summary: "",
|
|
87
|
-
author: "",
|
|
88
|
-
category: "",
|
|
89
|
-
photo: "",
|
|
90
|
-
video: "",
|
|
91
|
-
audio: "",
|
|
92
|
-
likeOf: "",
|
|
93
|
-
repostOf: "",
|
|
94
|
-
bookmarkOf: "",
|
|
95
|
-
inReplyTo: "",
|
|
96
|
-
source: "",
|
|
97
|
-
},
|
|
59
|
+
const ids = itemsToCleanup.map((item) => item._id);
|
|
60
|
+
const stripped = await collection.updateMany(
|
|
61
|
+
{ _id: { $in: ids } },
|
|
62
|
+
{
|
|
63
|
+
$set: { _stripped: true },
|
|
64
|
+
$unset: {
|
|
65
|
+
name: "",
|
|
66
|
+
content: "",
|
|
67
|
+
summary: "",
|
|
68
|
+
author: "",
|
|
69
|
+
category: "",
|
|
70
|
+
photo: "",
|
|
71
|
+
video: "",
|
|
72
|
+
audio: "",
|
|
73
|
+
likeOf: "",
|
|
74
|
+
repostOf: "",
|
|
75
|
+
bookmarkOf: "",
|
|
76
|
+
inReplyTo: "",
|
|
77
|
+
source: "",
|
|
98
78
|
},
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
totalCleaned += stripped.modifiedCount;
|
|
82
|
+
console.info(
|
|
83
|
+
`[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} items from channel "${channel.name}"`,
|
|
84
|
+
);
|
|
105
85
|
}
|
|
106
86
|
}
|
|
107
87
|
}
|
|
@@ -117,58 +97,187 @@ export async function cleanupAllReadItems(application) {
|
|
|
117
97
|
}
|
|
118
98
|
|
|
119
99
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
100
|
+
* Per-channel retention cleanup. For each channel (excluding `notifications`):
|
|
101
|
+
* 1. Drop unread items + stripped skeletons older than `maxUnreadAgeDays`.
|
|
102
|
+
* 2. Per-feed cap: keep most recent `maxItemsPerFeed` items per feed, drop the rest.
|
|
103
|
+
* 3. Channel-wide cap: keep most recent `maxItems` items total, drop the rest.
|
|
104
|
+
*
|
|
105
|
+
* Each channel uses its own `channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}`
|
|
106
|
+
* when present; otherwise the module-level defaults apply. This makes the policy
|
|
107
|
+
* configurable per channel — a noisy aggregator channel can set tight caps while
|
|
108
|
+
* a low-volume curated channel keeps a long tail.
|
|
109
|
+
*
|
|
110
|
+
* The order matters: per-feed cap runs before channel cap so a single prolific
|
|
111
|
+
* feed cannot starve other feeds in the channel of representation after the
|
|
112
|
+
* channel-wide trim.
|
|
113
|
+
*
|
|
123
114
|
* @param {object} application - Indiekit application
|
|
124
|
-
* @returns {Promise<number>} Total number of items deleted
|
|
115
|
+
* @returns {Promise<number>} Total number of items deleted across all channels
|
|
125
116
|
*/
|
|
126
117
|
export async function cleanupStaleItems(application) {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
|
|
130
|
-
|
|
131
|
-
// Delete stripped skeletons older than cutoff
|
|
132
|
-
const strippedResult = await collection.deleteMany({
|
|
133
|
-
_stripped: true,
|
|
134
|
-
$or: [
|
|
135
|
-
{ published: { $lt: cutoff } },
|
|
136
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
137
|
-
],
|
|
138
|
-
});
|
|
118
|
+
const itemsCollection = getCollection(application);
|
|
119
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
139
120
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
readBy: { $in: [null, []] },
|
|
143
|
-
_stripped: { $ne: true },
|
|
144
|
-
$or: [
|
|
145
|
-
{ published: { $lt: cutoff } },
|
|
146
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
147
|
-
],
|
|
148
|
-
});
|
|
121
|
+
const channels = await channelsCollection.find({}).toArray();
|
|
122
|
+
let totalDeleted = 0;
|
|
149
123
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
124
|
+
for (const channel of channels) {
|
|
125
|
+
// Notifications channel (webmentions) is exempt — high-signal, kept indefinitely.
|
|
126
|
+
if (channel.uid === "notifications") continue;
|
|
127
|
+
|
|
128
|
+
const settings = channel.settings || {};
|
|
129
|
+
const maxItems = settings.maxItems ?? DEFAULT_MAX_ITEMS;
|
|
130
|
+
const maxItemsPerFeed =
|
|
131
|
+
settings.maxItemsPerFeed ?? DEFAULT_MAX_ITEMS_PER_FEED;
|
|
132
|
+
const maxUnreadAgeDays =
|
|
133
|
+
settings.maxUnreadAgeDays ?? DEFAULT_MAX_UNREAD_AGE_DAYS;
|
|
134
|
+
|
|
135
|
+
const cutoff = new Date();
|
|
136
|
+
cutoff.setDate(cutoff.getDate() - maxUnreadAgeDays);
|
|
137
|
+
const cutoffIso = cutoff.toISOString();
|
|
138
|
+
let channelDeleted = 0;
|
|
139
|
+
let staleDeleted = 0;
|
|
140
|
+
let perFeedDeleted = 0;
|
|
141
|
+
let channelCapDeleted = 0;
|
|
142
|
+
|
|
143
|
+
// 1. Drop stripped skeletons older than cutoff (served their dedup purpose).
|
|
144
|
+
const strippedResult = await itemsCollection.deleteMany({
|
|
145
|
+
channelId: channel._id,
|
|
146
|
+
_stripped: true,
|
|
147
|
+
$or: [
|
|
148
|
+
{ published: { $lt: cutoff } },
|
|
149
|
+
{
|
|
150
|
+
published: { $exists: false },
|
|
151
|
+
createdAt: { $lt: cutoffIso },
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
staleDeleted += strippedResult.deletedCount;
|
|
156
|
+
|
|
157
|
+
// 1b. Drop unread (or never-read) items older than cutoff.
|
|
158
|
+
const unreadAgeResult = await itemsCollection.deleteMany({
|
|
159
|
+
channelId: channel._id,
|
|
160
|
+
_stripped: { $ne: true },
|
|
161
|
+
$and: [
|
|
162
|
+
{
|
|
163
|
+
$or: [
|
|
164
|
+
{ readBy: { $exists: false } },
|
|
165
|
+
{ readBy: { $size: 0 } },
|
|
166
|
+
{ readBy: null },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
$or: [
|
|
171
|
+
{ published: { $lt: cutoff } },
|
|
172
|
+
{
|
|
173
|
+
published: { $exists: false },
|
|
174
|
+
createdAt: { $lt: cutoffIso },
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
staleDeleted += unreadAgeResult.deletedCount;
|
|
181
|
+
channelDeleted += staleDeleted;
|
|
182
|
+
|
|
183
|
+
// 2. Per-feed cap. Iterate feeds in the channel; for each, delete oldest
|
|
184
|
+
// items beyond maxItemsPerFeed regardless of read state.
|
|
185
|
+
const feedIds = await itemsCollection.distinct("feedId", {
|
|
186
|
+
channelId: channel._id,
|
|
187
|
+
feedId: { $exists: true, $ne: null },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const feedId of feedIds) {
|
|
191
|
+
const excess = await itemsCollection
|
|
192
|
+
.find({ channelId: channel._id, feedId })
|
|
193
|
+
.sort({ published: -1, _id: -1 })
|
|
194
|
+
.skip(maxItemsPerFeed)
|
|
195
|
+
.project({ _id: 1 })
|
|
196
|
+
.toArray();
|
|
197
|
+
|
|
198
|
+
if (excess.length > 0) {
|
|
199
|
+
const ids = excess.map((item) => item._id);
|
|
200
|
+
const result = await itemsCollection.deleteMany({
|
|
201
|
+
_id: { $in: ids },
|
|
202
|
+
});
|
|
203
|
+
perFeedDeleted += result.deletedCount;
|
|
204
|
+
channelDeleted += result.deletedCount;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. Channel-wide cap. Catches items without feedId plus anything still over
|
|
209
|
+
// the per-channel ceiling after the per-feed pass.
|
|
210
|
+
const excessChannel = await itemsCollection
|
|
211
|
+
.find({ channelId: channel._id })
|
|
212
|
+
.sort({ published: -1, _id: -1 })
|
|
213
|
+
.skip(maxItems)
|
|
214
|
+
.project({ _id: 1 })
|
|
215
|
+
.toArray();
|
|
216
|
+
|
|
217
|
+
if (excessChannel.length > 0) {
|
|
218
|
+
const ids = excessChannel.map((item) => item._id);
|
|
219
|
+
const result = await itemsCollection.deleteMany({ _id: { $in: ids } });
|
|
220
|
+
channelCapDeleted += result.deletedCount;
|
|
221
|
+
channelDeleted += result.deletedCount;
|
|
222
|
+
}
|
|
159
223
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
224
|
+
if (channelDeleted > 0) {
|
|
225
|
+
console.info(
|
|
226
|
+
`[Microsub] Retention cleanup "${channel.name}": deleted ${channelDeleted} items ` +
|
|
227
|
+
`(stale: ${staleDeleted}, per-feed: ${perFeedDeleted}, channel-cap: ${channelCapDeleted}; ` +
|
|
228
|
+
`maxItems=${maxItems}, maxItemsPerFeed=${maxItemsPerFeed}, maxUnreadAgeDays=${maxUnreadAgeDays})`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
totalDeleted += channelDeleted;
|
|
232
|
+
}
|
|
164
233
|
|
|
165
|
-
if (
|
|
234
|
+
if (totalDeleted > 0) {
|
|
166
235
|
console.info(
|
|
167
|
-
`[Microsub]
|
|
168
|
-
`${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
|
|
169
|
-
`(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
|
|
236
|
+
`[Microsub] Retention cleanup complete: ${totalDeleted} total items deleted across ${channels.length} channels`,
|
|
170
237
|
);
|
|
171
238
|
}
|
|
172
239
|
|
|
173
|
-
return
|
|
240
|
+
return totalDeleted;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One-time migration: remove the abandoned "Fediverse" channel and its items.
|
|
245
|
+
* The microsub reader briefly tried to ingest ActivityPub outboxes into a
|
|
246
|
+
* dedicated channel (uid: "activitypub"). That feature was abandoned — fediverse
|
|
247
|
+
* federation lives entirely in the separate `indiekit-endpoint-activitypub`
|
|
248
|
+
* plugin now. This migration cleans up the leftover channel and items.
|
|
249
|
+
* Idempotent — safe to run on every startup.
|
|
250
|
+
* @param {object} application - Indiekit application
|
|
251
|
+
* @returns {Promise<{ channelsRemoved: number, itemsRemoved: number }>}
|
|
252
|
+
*/
|
|
253
|
+
export async function removeActivityPubData(application) {
|
|
254
|
+
const itemsCollection = getCollection(application);
|
|
255
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
256
|
+
|
|
257
|
+
const apChannels = await channelsCollection
|
|
258
|
+
.find({ uid: "activitypub" })
|
|
259
|
+
.toArray();
|
|
260
|
+
|
|
261
|
+
if (apChannels.length === 0) {
|
|
262
|
+
return { channelsRemoved: 0, itemsRemoved: 0 };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const channelIds = apChannels.map((c) => c._id);
|
|
266
|
+
|
|
267
|
+
const itemsResult = await itemsCollection.deleteMany({
|
|
268
|
+
channelId: { $in: channelIds },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const channelsResult = await channelsCollection.deleteMany({
|
|
272
|
+
_id: { $in: channelIds },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
console.info(
|
|
276
|
+
`[Microsub] Removed abandoned Fediverse channel: ${channelsResult.deletedCount} channel(s), ${itemsResult.deletedCount} item(s)`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
channelsRemoved: channelsResult.deletedCount,
|
|
281
|
+
itemsRemoved: itemsResult.deletedCount,
|
|
282
|
+
};
|
|
174
283
|
}
|
|
@@ -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/sanitize.js
CHANGED
package/lib/utils/validation.js
CHANGED
|
@@ -163,6 +163,31 @@ export function validateExcludeTypes(types) {
|
|
|
163
163
|
return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Validate a per-channel retention numeric setting.
|
|
168
|
+
* Accepts a positive integer; returns undefined for empty/invalid input so the
|
|
169
|
+
* caller can fall back to the global default.
|
|
170
|
+
* @param {string|number|undefined} value - Raw form value
|
|
171
|
+
* @param {object} [options]
|
|
172
|
+
* @param {number} [options.min] - Minimum allowed value (inclusive). Default 1.
|
|
173
|
+
* @param {number} [options.max] - Maximum allowed value (inclusive). Default 1_000_000.
|
|
174
|
+
* @returns {number|undefined} Validated integer, or undefined if empty/invalid
|
|
175
|
+
*/
|
|
176
|
+
export function validateRetentionSetting(value, options = {}) {
|
|
177
|
+
const { min = 1, max = 1_000_000 } = options;
|
|
178
|
+
|
|
179
|
+
if (value === undefined || value === null || value === "") {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
184
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return parsed;
|
|
189
|
+
}
|
|
190
|
+
|
|
166
191
|
/**
|
|
167
192
|
* Validate regex pattern
|
|
168
193
|
* @param {string} pattern - Regex pattern to validate
|
|
@@ -30,7 +30,7 @@ export async function processWebmention(application, source, target, userId) {
|
|
|
30
30
|
const verification = await verifyWebmention(source, target);
|
|
31
31
|
|
|
32
32
|
if (!verification.verified) {
|
|
33
|
-
console.
|
|
33
|
+
console.info(
|
|
34
34
|
`[Microsub] Webmention verification failed: ${verification.error}`,
|
|
35
35
|
);
|
|
36
36
|
return {
|
|
@@ -87,7 +87,7 @@ export async function processWebmention(application, source, target, userId) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
console.
|
|
90
|
+
console.info(
|
|
91
91
|
`[Microsub] Webmention processed: ${verification.type} from ${source}`,
|
|
92
92
|
);
|
|
93
93
|
|
package/lib/websub/handler.js
CHANGED
|
@@ -64,7 +64,7 @@ export async function verify(request, response) {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
console.
|
|
67
|
+
console.info(`[Microsub] WebSub subscription verified for ${feed.url}`);
|
|
68
68
|
|
|
69
69
|
// Return challenge to verify subscription
|
|
70
70
|
response.type("text/plain").send(challenge);
|
|
@@ -143,7 +143,7 @@ async function processWebsubContent(application, feed, contentType, body) {
|
|
|
143
143
|
// Parse the pushed content
|
|
144
144
|
const parsed = await parseFeed(content, feed.url, { contentType });
|
|
145
145
|
|
|
146
|
-
console.
|
|
146
|
+
console.info(
|
|
147
147
|
`[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
|
|
148
148
|
);
|
|
149
149
|
|
package/locales/en.json
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"showRead": "Show read ({{count}})",
|
|
11
11
|
"hideRead": "Hide read items",
|
|
12
12
|
"allRead": "All caught up!",
|
|
13
|
-
"newer": "Newer",
|
|
14
13
|
"older": "Older"
|
|
15
14
|
},
|
|
16
15
|
"channels": {
|
|
@@ -18,34 +17,21 @@
|
|
|
18
17
|
"name": "Channel name",
|
|
19
18
|
"new": "New channel",
|
|
20
19
|
"create": "Create channel",
|
|
21
|
-
"delete": "Delete channel",
|
|
22
20
|
"settings": "Channel settings",
|
|
23
|
-
"empty": "No channels yet. Create one to get started."
|
|
24
|
-
"notifications": "Notifications"
|
|
21
|
+
"empty": "No channels yet. Create one to get started."
|
|
25
22
|
},
|
|
26
23
|
"timeline": {
|
|
27
|
-
"title": "Timeline",
|
|
28
24
|
"empty": "No items in this channel",
|
|
29
|
-
"markRead": "Mark as read"
|
|
30
|
-
"markUnread": "Mark as unread",
|
|
31
|
-
"remove": "Remove"
|
|
25
|
+
"markRead": "Mark as read"
|
|
32
26
|
},
|
|
33
27
|
"feeds": {
|
|
34
28
|
"title": "Feeds",
|
|
35
29
|
"follow": "Follow",
|
|
36
30
|
"subscribe": "Subscribe to a feed",
|
|
37
|
-
"unfollow": "Unfollow",
|
|
38
31
|
"empty": "No feeds followed in this channel",
|
|
39
32
|
"url": "Feed URL",
|
|
40
33
|
"urlPlaceholder": "https://example.com/feed.xml",
|
|
41
|
-
"edit": "Edit feed"
|
|
42
|
-
"rediscover": "Rediscover feed",
|
|
43
|
-
"refresh": "Refresh now",
|
|
44
|
-
"status": {
|
|
45
|
-
"active": "Active",
|
|
46
|
-
"error": "Error",
|
|
47
|
-
"stale": "Stale"
|
|
48
|
-
}
|
|
34
|
+
"edit": "Edit feed"
|
|
49
35
|
},
|
|
50
36
|
"item": {
|
|
51
37
|
"reply": "Reply",
|
|
@@ -75,6 +61,14 @@
|
|
|
75
61
|
"excludeRegex": "Exclude pattern",
|
|
76
62
|
"excludeRegexHelp": "Regular expression to filter out matching content",
|
|
77
63
|
"save": "Save settings",
|
|
64
|
+
"retentionTitle": "Retention",
|
|
65
|
+
"retentionHelp": "Control how many items this channel keeps in MongoDB. Useful for high-volume aggregator channels that would otherwise grow without bound. Leave any field blank to use the global default.",
|
|
66
|
+
"maxItems": "Maximum items in channel",
|
|
67
|
+
"maxItemsHelp": "Keep at most this many items total. Oldest items are deleted regardless of read state. Default: {{default}}.",
|
|
68
|
+
"maxItemsPerFeed": "Maximum items per feed",
|
|
69
|
+
"maxItemsPerFeedHelp": "Keep at most this many items per feed inside this channel. Prevents one prolific feed from monopolising the channel cap. Default: {{default}}.",
|
|
70
|
+
"maxUnreadAgeDays": "Drop unread items older than (days)",
|
|
71
|
+
"maxUnreadAgeDaysHelp": "Unread items older than this are deleted, even if the channel is below its item cap. Default: {{default}}.",
|
|
78
72
|
"dangerZone": "Danger zone",
|
|
79
73
|
"deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
|
|
80
74
|
"deleteConfirm": "Are you sure you want to delete this channel and all its content?",
|
|
@@ -93,20 +87,10 @@
|
|
|
93
87
|
"submit": "Search",
|
|
94
88
|
"noResults": "No results found"
|
|
95
89
|
},
|
|
96
|
-
"preview": {
|
|
97
|
-
"title": "Preview",
|
|
98
|
-
"subscribe": "Subscribe to this feed"
|
|
99
|
-
},
|
|
100
90
|
"views": {
|
|
101
91
|
"channels": "Channels",
|
|
102
92
|
"deck": "Deck",
|
|
103
93
|
"timeline": "Timeline"
|
|
104
|
-
},
|
|
105
|
-
"error": {
|
|
106
|
-
"channelNotFound": "Channel not found",
|
|
107
|
-
"feedNotFound": "Feed not found",
|
|
108
|
-
"invalidUrl": "Invalid URL",
|
|
109
|
-
"invalidAction": "Invalid action"
|
|
110
94
|
}
|
|
111
95
|
}
|
|
112
96
|
}
|
package/package.json
CHANGED