@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 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
- // Ensure system channels exist
218
- ensureActivityPubChannel(indiekit).catch((error) => {
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 channel creation failed:",
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 { excludeTypes, excludeRegex } = request.body;
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" || uid === "activitypub") {
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,
@@ -35,7 +35,7 @@ export function startScheduler(indiekit) {
35
35
  // Run immediately on start
36
36
  runSchedulerCycle();
37
37
 
38
- console.log("[Microsub] Feed polling scheduler started");
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.log("[Microsub] Feed polling scheduler stopped");
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.log(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
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.log(
83
+ console.info(
84
84
  `[Microsub] Processed ${result.total} feeds: ${result.successful} successful, ` +
85
85
  `${result.failed} failed, ${result.itemsAdded} new items`,
86
86
  );
@@ -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" || uid === "activitypub") {
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 { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
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
- * This prevents the vicious cycle where deleted read items get re-ingested as
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, feedId: 1 })
38
+ .project({ _id: 1 })
45
39
  .toArray();
46
40
 
47
41
  if (itemsToCleanup.length === 0) return;
48
42
 
49
- // Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
50
- const apItemIds = [];
51
- const rssItemIds = [];
52
- for (const item of itemsToCleanup) {
53
- if (item.feedId) {
54
- rssItemIds.push(item._id);
55
- } else {
56
- apItemIds.push(item._id);
57
- }
58
- }
59
-
60
- // Hard-delete AP items — no poller to re-ingest, skeletons are useless
61
- if (apItemIds.length > 0) {
62
- const deleted = await collection.deleteMany({
63
- _id: { $in: apItemIds },
64
- });
65
- console.info(
66
- `[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
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
- console.info(
94
- `[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
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