@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
package/index.js
CHANGED
|
@@ -12,11 +12,11 @@ import { asyncHandler } from "./lib/utils/async-handler.js";
|
|
|
12
12
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
13
13
|
import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
|
|
14
14
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
15
|
-
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
16
15
|
import { createIndexes } from "./lib/storage/items.js";
|
|
17
16
|
import {
|
|
18
17
|
cleanupAllReadItems,
|
|
19
18
|
cleanupStaleItems,
|
|
19
|
+
removeActivityPubData,
|
|
20
20
|
} from "./lib/storage/items-retention.js";
|
|
21
21
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
22
22
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
@@ -133,9 +133,6 @@ export default class MicrosubEndpoint {
|
|
|
133
133
|
readerRouter.get("/search", asyncHandler(readerController.searchPage));
|
|
134
134
|
readerRouter.post("/search", asyncHandler(readerController.searchFeeds));
|
|
135
135
|
readerRouter.post("/subscribe", asyncHandler(readerController.subscribe));
|
|
136
|
-
readerRouter.get("/actor", asyncHandler(readerController.actorProfile));
|
|
137
|
-
readerRouter.post("/actor/follow", asyncHandler(readerController.followActorAction));
|
|
138
|
-
readerRouter.post("/actor/unfollow", asyncHandler(readerController.unfollowActorAction));
|
|
139
136
|
readerRouter.post("/api/mark-read", asyncHandler(readerController.markAllRead));
|
|
140
137
|
readerRouter.post("/api/mark-view-read", asyncHandler(readerController.markViewRead));
|
|
141
138
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
@@ -214,10 +211,11 @@ export default class MicrosubEndpoint {
|
|
|
214
211
|
console.info("[Microsub] Starting scheduler and maintenance tasks");
|
|
215
212
|
startScheduler(indiekit);
|
|
216
213
|
|
|
217
|
-
//
|
|
218
|
-
|
|
214
|
+
// One-time migration: drop the abandoned "Fediverse" channel and its
|
|
215
|
+
// items. Idempotent — does nothing once the channel is gone.
|
|
216
|
+
removeActivityPubData(indiekit).catch((error) => {
|
|
219
217
|
console.warn(
|
|
220
|
-
"[Microsub] ActivityPub
|
|
218
|
+
"[Microsub] ActivityPub data removal failed:",
|
|
221
219
|
error.message,
|
|
222
220
|
);
|
|
223
221
|
});
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
updateChannelSettings,
|
|
11
11
|
deleteChannel,
|
|
12
12
|
} from "../../storage/channels.js";
|
|
13
|
-
import { getFeedsForChannel } from "../../storage/feeds.js";
|
|
14
13
|
import { getTimelineItems } from "../../storage/items.js";
|
|
15
14
|
import { countReadItems } from "../../storage/items-read-state.js";
|
|
16
15
|
import { getUserId } from "../../utils/auth.js";
|
|
@@ -18,7 +17,13 @@ import {
|
|
|
18
17
|
validateChannelName,
|
|
19
18
|
validateExcludeTypes,
|
|
20
19
|
validateExcludeRegex,
|
|
20
|
+
validateRetentionSetting,
|
|
21
21
|
} from "../../utils/validation.js";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_MAX_ITEMS,
|
|
24
|
+
DEFAULT_MAX_ITEMS_PER_FEED,
|
|
25
|
+
DEFAULT_MAX_UNREAD_AGE_DAYS,
|
|
26
|
+
} from "../../storage/items-retention.js";
|
|
22
27
|
import { proxyItemImages } from "../../media/proxy.js";
|
|
23
28
|
|
|
24
29
|
/**
|
|
@@ -212,6 +217,14 @@ export async function channelHtml(request, response) {
|
|
|
212
217
|
* @param {object} response - Express response
|
|
213
218
|
* @returns {Promise<void>}
|
|
214
219
|
*/
|
|
220
|
+
// Defaults exposed to the settings template so placeholders show what the
|
|
221
|
+
// channel falls back to when a field is left blank.
|
|
222
|
+
const RETENTION_DEFAULTS = {
|
|
223
|
+
maxItems: DEFAULT_MAX_ITEMS,
|
|
224
|
+
maxItemsPerFeed: DEFAULT_MAX_ITEMS_PER_FEED,
|
|
225
|
+
maxUnreadAgeDays: DEFAULT_MAX_UNREAD_AGE_DAYS,
|
|
226
|
+
};
|
|
227
|
+
|
|
215
228
|
export async function settings(request, response) {
|
|
216
229
|
const { application } = request.app.locals;
|
|
217
230
|
const userId = getUserId(request);
|
|
@@ -236,6 +249,7 @@ export async function settings(request, response) {
|
|
|
236
249
|
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
237
250
|
{ text: "Settings" },
|
|
238
251
|
],
|
|
252
|
+
retentionDefaults: RETENTION_DEFAULTS,
|
|
239
253
|
});
|
|
240
254
|
}
|
|
241
255
|
|
|
@@ -249,7 +263,13 @@ export async function updateSettings(request, response) {
|
|
|
249
263
|
const { application } = request.app.locals;
|
|
250
264
|
const userId = getUserId(request);
|
|
251
265
|
const { uid } = request.params;
|
|
252
|
-
const {
|
|
266
|
+
const {
|
|
267
|
+
excludeTypes,
|
|
268
|
+
excludeRegex,
|
|
269
|
+
maxItems,
|
|
270
|
+
maxItemsPerFeed,
|
|
271
|
+
maxUnreadAgeDays,
|
|
272
|
+
} = request.body;
|
|
253
273
|
|
|
254
274
|
const channelDocument = await getChannel(application, uid, userId);
|
|
255
275
|
if (!channelDocument) {
|
|
@@ -261,12 +281,30 @@ export async function updateSettings(request, response) {
|
|
|
261
281
|
);
|
|
262
282
|
const validatedRegex = validateExcludeRegex(excludeRegex);
|
|
263
283
|
|
|
284
|
+
// Retention overrides. Empty/invalid input falls back to the global default
|
|
285
|
+
// (stored as undefined so future global default changes apply automatically).
|
|
286
|
+
const validatedMaxItems = validateRetentionSetting(maxItems, {
|
|
287
|
+
min: 10,
|
|
288
|
+
max: 100_000,
|
|
289
|
+
});
|
|
290
|
+
const validatedMaxItemsPerFeed = validateRetentionSetting(maxItemsPerFeed, {
|
|
291
|
+
min: 1,
|
|
292
|
+
max: 10_000,
|
|
293
|
+
});
|
|
294
|
+
const validatedMaxUnreadAgeDays = validateRetentionSetting(
|
|
295
|
+
maxUnreadAgeDays,
|
|
296
|
+
{ min: 1, max: 3650 },
|
|
297
|
+
);
|
|
298
|
+
|
|
264
299
|
await updateChannelSettings(
|
|
265
300
|
application,
|
|
266
301
|
uid,
|
|
267
302
|
{
|
|
268
303
|
excludeTypes: validatedTypes,
|
|
269
304
|
excludeRegex: validatedRegex,
|
|
305
|
+
maxItems: validatedMaxItems,
|
|
306
|
+
maxItemsPerFeed: validatedMaxItemsPerFeed,
|
|
307
|
+
maxUnreadAgeDays: validatedMaxUnreadAgeDays,
|
|
270
308
|
},
|
|
271
309
|
userId,
|
|
272
310
|
);
|
|
@@ -286,7 +324,7 @@ export async function deleteChannelAction(request, response) {
|
|
|
286
324
|
const { uid } = request.params;
|
|
287
325
|
|
|
288
326
|
// Don't allow deleting system channels
|
|
289
|
-
if (uid === "notifications"
|
|
327
|
+
if (uid === "notifications") {
|
|
290
328
|
return response.redirect(`${request.baseUrl}/channels`);
|
|
291
329
|
}
|
|
292
330
|
|
|
@@ -38,12 +38,6 @@ export { compose, submitCompose } from "./compose.js";
|
|
|
38
38
|
|
|
39
39
|
export { searchPage, searchFeeds, subscribe } from "./search.js";
|
|
40
40
|
|
|
41
|
-
export {
|
|
42
|
-
actorProfile,
|
|
43
|
-
followActorAction,
|
|
44
|
-
unfollowActorAction,
|
|
45
|
-
} from "./actor.js";
|
|
46
|
-
|
|
47
41
|
export { deck, deckSettings, saveDeckSettings } from "./deck.js";
|
|
48
42
|
|
|
49
43
|
import {
|
|
@@ -81,12 +75,6 @@ import { compose, submitCompose } from "./compose.js";
|
|
|
81
75
|
|
|
82
76
|
import { searchPage, searchFeeds, subscribe } from "./search.js";
|
|
83
77
|
|
|
84
|
-
import {
|
|
85
|
-
actorProfile,
|
|
86
|
-
followActorAction,
|
|
87
|
-
unfollowActorAction,
|
|
88
|
-
} from "./actor.js";
|
|
89
|
-
|
|
90
78
|
import { deck, deckSettings, saveDeckSettings } from "./deck.js";
|
|
91
79
|
|
|
92
80
|
export const readerController = {
|
|
@@ -115,9 +103,6 @@ export const readerController = {
|
|
|
115
103
|
searchPage,
|
|
116
104
|
searchFeeds,
|
|
117
105
|
subscribe,
|
|
118
|
-
actorProfile,
|
|
119
|
-
followActorAction,
|
|
120
|
-
unfollowActorAction,
|
|
121
106
|
timeline,
|
|
122
107
|
timelineHtml,
|
|
123
108
|
deck,
|
package/lib/polling/scheduler.js
CHANGED
|
@@ -35,7 +35,7 @@ export function startScheduler(indiekit) {
|
|
|
35
35
|
// Run immediately on start
|
|
36
36
|
runSchedulerCycle();
|
|
37
37
|
|
|
38
|
-
console.
|
|
38
|
+
console.info("[Microsub] Feed polling scheduler started");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -47,7 +47,7 @@ export function stopScheduler() {
|
|
|
47
47
|
schedulerInterval = undefined;
|
|
48
48
|
}
|
|
49
49
|
indiekitInstance = undefined;
|
|
50
|
-
console.
|
|
50
|
+
console.info("[Microsub] Feed polling scheduler stopped");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
@@ -74,13 +74,13 @@ async function runSchedulerCycle() {
|
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
console.
|
|
77
|
+
console.info(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
|
|
78
78
|
|
|
79
79
|
const result = await processFeedBatch(application, feeds, {
|
|
80
80
|
concurrency: BATCH_CONCURRENCY,
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
console.
|
|
83
|
+
console.info(
|
|
84
84
|
`[Microsub] Processed ${result.total} feeds: ${result.successful} successful, ` +
|
|
85
85
|
`${result.failed} failed, ${result.itemsAdded} new items`,
|
|
86
86
|
);
|
package/lib/storage/channels.js
CHANGED
|
@@ -298,7 +298,7 @@ export async function deleteChannel(application, uid, userId) {
|
|
|
298
298
|
if (userId) query.userId = userId;
|
|
299
299
|
|
|
300
300
|
// Don't allow deleting system channels
|
|
301
|
-
if (uid === "notifications"
|
|
301
|
+
if (uid === "notifications") {
|
|
302
302
|
return false;
|
|
303
303
|
}
|
|
304
304
|
|
|
@@ -396,39 +396,3 @@ export async function ensureNotificationsChannel(application, userId) {
|
|
|
396
396
|
await collection.insertOne(channel);
|
|
397
397
|
return channel;
|
|
398
398
|
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Ensure ActivityPub channel exists
|
|
402
|
-
* @param {object} application - Indiekit application
|
|
403
|
-
* @param {string} [userId] - User ID
|
|
404
|
-
* @returns {Promise<object>} ActivityPub channel
|
|
405
|
-
*/
|
|
406
|
-
export async function ensureActivityPubChannel(application, userId) {
|
|
407
|
-
const collection = getCollection(application);
|
|
408
|
-
|
|
409
|
-
const existing = await collection.findOne({
|
|
410
|
-
uid: "activitypub",
|
|
411
|
-
...(userId && { userId }),
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
if (existing) {
|
|
415
|
-
return existing;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const channel = {
|
|
419
|
-
uid: "activitypub",
|
|
420
|
-
name: "Fediverse",
|
|
421
|
-
userId,
|
|
422
|
-
source: "activitypub",
|
|
423
|
-
order: -0.5, // After notifications (-1), before user channels (0+)
|
|
424
|
-
settings: {
|
|
425
|
-
excludeTypes: [],
|
|
426
|
-
excludeRegex: undefined,
|
|
427
|
-
},
|
|
428
|
-
createdAt: new Date().toISOString(),
|
|
429
|
-
updatedAt: new Date().toISOString(),
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
await collection.insertOne(channel);
|
|
433
|
-
return channel;
|
|
434
|
-
}
|
|
@@ -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
|
|