@rmdes/indiekit-endpoint-microsub 1.0.41 → 1.0.43
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 +10 -1
- package/lib/controllers/timeline.js +11 -3
- package/lib/storage/items.js +61 -0
- package/package.json +1 -1
- package/views/partials/item-card.njk +1 -0
- package/views/timeline.njk +3 -2
package/index.js
CHANGED
|
@@ -9,7 +9,11 @@ import { readerController } from "./lib/controllers/reader.js";
|
|
|
9
9
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
10
10
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
11
11
|
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
cleanupAllReadItems,
|
|
14
|
+
cleanupStaleItems,
|
|
15
|
+
createIndexes,
|
|
16
|
+
} from "./lib/storage/items.js";
|
|
13
17
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
14
18
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
15
19
|
|
|
@@ -210,6 +214,11 @@ export default class MicrosubEndpoint {
|
|
|
210
214
|
cleanupAllReadItems(indiekit).catch((error) => {
|
|
211
215
|
console.warn("[Microsub] Startup cleanup failed:", error.message);
|
|
212
216
|
});
|
|
217
|
+
|
|
218
|
+
// Delete stale items (stripped skeletons + unread older than 30 days)
|
|
219
|
+
cleanupStaleItems(indiekit).catch((error) => {
|
|
220
|
+
console.warn("[Microsub] Stale cleanup failed:", error.message);
|
|
221
|
+
});
|
|
213
222
|
} else {
|
|
214
223
|
console.warn(
|
|
215
224
|
"[Microsub] Database not available at init, scheduler not started",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { IndiekitError } from "@indiekit/error";
|
|
7
7
|
|
|
8
8
|
import { proxyItemImages } from "../media/proxy.js";
|
|
9
|
-
import { getChannel } from "../storage/channels.js";
|
|
9
|
+
import { getChannel, getChannelById } from "../storage/channels.js";
|
|
10
10
|
import {
|
|
11
11
|
getTimelineItems,
|
|
12
12
|
markItemsRead,
|
|
@@ -72,8 +72,16 @@ export async function action(request, response) {
|
|
|
72
72
|
|
|
73
73
|
validateChannel(channel);
|
|
74
74
|
|
|
75
|
-
// Verify channel exists
|
|
76
|
-
|
|
75
|
+
// Verify channel exists — try by UID first, fall back to ObjectId
|
|
76
|
+
// (timeline view may send ObjectId string for items from orphan channels)
|
|
77
|
+
let channelDocument = await getChannel(application, channel, userId);
|
|
78
|
+
if (!channelDocument) {
|
|
79
|
+
try {
|
|
80
|
+
channelDocument = await getChannelById(application, channel);
|
|
81
|
+
} catch {
|
|
82
|
+
// Invalid ObjectId format — channel string is not a valid ObjectId
|
|
83
|
+
}
|
|
84
|
+
}
|
|
77
85
|
if (!channelDocument) {
|
|
78
86
|
throw new IndiekitError("Channel not found", {
|
|
79
87
|
status: 404,
|
package/lib/storage/items.js
CHANGED
|
@@ -387,6 +387,10 @@ export async function countReadItems(application, channelId, userId) {
|
|
|
387
387
|
// uid, readBy) so the poller doesn't re-ingest them as new unread entries.
|
|
388
388
|
const MAX_FULL_READ_ITEMS = 200;
|
|
389
389
|
|
|
390
|
+
// Maximum age (in days) for stripped skeletons and unread items.
|
|
391
|
+
// After this period, both are hard-deleted to prevent unbounded growth.
|
|
392
|
+
const MAX_ITEM_AGE_DAYS = 30;
|
|
393
|
+
|
|
390
394
|
/**
|
|
391
395
|
* Cleanup old read items by stripping content but preserving dedup skeletons.
|
|
392
396
|
* This prevents the vicious cycle where deleted read items get re-ingested as
|
|
@@ -575,6 +579,63 @@ export async function cleanupAllReadItems(application) {
|
|
|
575
579
|
return totalCleaned;
|
|
576
580
|
}
|
|
577
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Delete stale items: stripped skeletons and unread items older than MAX_ITEM_AGE_DAYS.
|
|
584
|
+
* Stripped skeletons have served their dedup purpose; stale unread items are unlikely
|
|
585
|
+
* to be read. Both are hard-deleted to prevent unbounded collection growth.
|
|
586
|
+
* @param {object} application - Indiekit application
|
|
587
|
+
* @returns {Promise<number>} Total number of items deleted
|
|
588
|
+
*/
|
|
589
|
+
export async function cleanupStaleItems(application) {
|
|
590
|
+
const collection = getCollection(application);
|
|
591
|
+
const cutoff = new Date();
|
|
592
|
+
cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
|
|
593
|
+
|
|
594
|
+
// Delete stripped skeletons older than cutoff
|
|
595
|
+
const strippedResult = await collection.deleteMany({
|
|
596
|
+
_stripped: true,
|
|
597
|
+
$or: [
|
|
598
|
+
{ published: { $lt: cutoff } },
|
|
599
|
+
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
600
|
+
],
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Delete unread items older than cutoff
|
|
604
|
+
const unreadResult = await collection.deleteMany({
|
|
605
|
+
readBy: { $in: [null, []] },
|
|
606
|
+
_stripped: { $ne: true },
|
|
607
|
+
$or: [
|
|
608
|
+
{ published: { $lt: cutoff } },
|
|
609
|
+
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
610
|
+
],
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Also catch items with no readBy field at all
|
|
614
|
+
const noReadByResult = await collection.deleteMany({
|
|
615
|
+
readBy: { $exists: false },
|
|
616
|
+
_stripped: { $ne: true },
|
|
617
|
+
$or: [
|
|
618
|
+
{ published: { $lt: cutoff } },
|
|
619
|
+
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
620
|
+
],
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const total =
|
|
624
|
+
strippedResult.deletedCount +
|
|
625
|
+
unreadResult.deletedCount +
|
|
626
|
+
noReadByResult.deletedCount;
|
|
627
|
+
|
|
628
|
+
if (total > 0) {
|
|
629
|
+
console.info(
|
|
630
|
+
`[Microsub] Stale cleanup: deleted ${strippedResult.deletedCount} stripped skeletons, ` +
|
|
631
|
+
`${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
|
|
632
|
+
`(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return total;
|
|
637
|
+
}
|
|
638
|
+
|
|
578
639
|
export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
579
640
|
const collection = getCollection(application);
|
|
580
641
|
const channelObjectId =
|
package/package.json
CHANGED
|
@@ -203,6 +203,7 @@
|
|
|
203
203
|
data-action="mark-read"
|
|
204
204
|
data-item-id="{{ item._id }}"
|
|
205
205
|
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
|
|
206
|
+
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}
|
|
206
207
|
title="Mark as read">
|
|
207
208
|
{{ icon("checkboxChecked") }}
|
|
208
209
|
<span class="visually-hidden">Mark read</span>
|
package/views/timeline.njk
CHANGED
|
@@ -108,7 +108,8 @@
|
|
|
108
108
|
|
|
109
109
|
const itemId = button.dataset.itemId;
|
|
110
110
|
const channelUid = button.dataset.channelUid;
|
|
111
|
-
|
|
111
|
+
const channelId = button.dataset.channelId;
|
|
112
|
+
if (!itemId || (!channelUid && !channelId)) return;
|
|
112
113
|
|
|
113
114
|
button.disabled = true;
|
|
114
115
|
|
|
@@ -116,7 +117,7 @@
|
|
|
116
117
|
const formData = new URLSearchParams();
|
|
117
118
|
formData.append('action', 'timeline');
|
|
118
119
|
formData.append('method', 'mark_read');
|
|
119
|
-
formData.append('channel', channelUid);
|
|
120
|
+
formData.append('channel', channelUid || channelId);
|
|
120
121
|
formData.append('entry', itemId);
|
|
121
122
|
|
|
122
123
|
const response = await fetch(microsubApiUrl, {
|