@rmdes/indiekit-endpoint-microsub 1.0.42 → 1.0.44

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
@@ -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 { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
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",
@@ -67,13 +67,24 @@ export async function follow(request, response) {
67
67
  throw new IndiekitError("Channel not found", { status: 404 });
68
68
  }
69
69
 
70
- // Create feed subscription
71
- const feed = await createFeed(application, {
72
- channelId: channelDocument._id,
73
- url,
74
- title: undefined, // Will be populated on first fetch
75
- photo: undefined,
76
- });
70
+ // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
71
+ let feed;
72
+ try {
73
+ feed = await createFeed(application, {
74
+ channelId: channelDocument._id,
75
+ url,
76
+ title: undefined, // Will be populated on first fetch
77
+ photo: undefined,
78
+ });
79
+ } catch (error) {
80
+ if (error.code === "DUPLICATE_FEED") {
81
+ throw new IndiekitError(
82
+ `Feed already exists in channel "${error.channelName}"`,
83
+ { status: 409 },
84
+ );
85
+ }
86
+ throw error;
87
+ }
77
88
 
78
89
  // Trigger immediate fetch in background (don't await)
79
90
  // This will also discover and subscribe to WebSub hubs
@@ -319,20 +319,43 @@ export async function addFeed(request, response) {
319
319
  return response.status(404).render("404");
320
320
  }
321
321
 
322
- // Create feed subscription
323
- const feed = await createFeed(application, {
324
- channelId: channelDocument._id,
325
- url,
326
- title: undefined,
327
- photo: undefined,
328
- });
322
+ try {
323
+ // Create feed subscription (throws DUPLICATE_FEED if already exists)
324
+ const feed = await createFeed(application, {
325
+ channelId: channelDocument._id,
326
+ url,
327
+ title: undefined,
328
+ photo: undefined,
329
+ });
329
330
 
330
- // Trigger immediate fetch in background
331
- refreshFeedNow(application, feed._id).catch((error) => {
332
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
333
- });
331
+ // Trigger immediate fetch in background
332
+ refreshFeedNow(application, feed._id).catch((error) => {
333
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
334
+ });
334
335
 
335
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
336
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
337
+ } catch (error) {
338
+ if (error.code === "DUPLICATE_FEED") {
339
+ // Re-render feeds page with error message
340
+ const feedList = await getFeedsForChannel(application, channelDocument._id);
341
+ return response.render("feeds", {
342
+ title: request.__("microsub.feeds.title"),
343
+ channel: channelDocument,
344
+ feeds: feedList,
345
+ baseUrl: request.baseUrl,
346
+ readerBaseUrl: request.baseUrl,
347
+ activeView: "channels",
348
+ error: `This feed already exists in channel "${error.channelName}"`,
349
+ breadcrumbs: [
350
+ { text: "Reader", href: request.baseUrl },
351
+ { text: "Channels", href: `${request.baseUrl}/channels` },
352
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
353
+ { text: "Feeds" },
354
+ ],
355
+ });
356
+ }
357
+ throw error;
358
+ }
336
359
  }
337
360
 
338
361
  /**
@@ -782,20 +805,40 @@ export async function subscribe(request, response) {
782
805
  }
783
806
  }
784
807
 
785
- // Create feed subscription
786
- const feed = await createFeed(application, {
787
- channelId: channelDocument._id,
788
- url,
789
- title: undefined,
790
- photo: undefined,
791
- });
808
+ // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
809
+ try {
810
+ const feed = await createFeed(application, {
811
+ channelId: channelDocument._id,
812
+ url,
813
+ title: undefined,
814
+ photo: undefined,
815
+ });
792
816
 
793
- // Trigger immediate fetch in background
794
- refreshFeedNow(application, feed._id).catch((error) => {
795
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
796
- });
817
+ // Trigger immediate fetch in background
818
+ refreshFeedNow(application, feed._id).catch((error) => {
819
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
820
+ });
797
821
 
798
- response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
822
+ response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
823
+ } catch (error) {
824
+ if (error.code === "DUPLICATE_FEED") {
825
+ const channelList = await getChannels(application, userId);
826
+ return response.render("search", {
827
+ title: request.__("microsub.search.title"),
828
+ channels: channelList,
829
+ query: url,
830
+ validationError: `This feed already exists in channel "${error.channelName}"`,
831
+ baseUrl: request.baseUrl,
832
+ readerBaseUrl: request.baseUrl,
833
+ activeView: "channels",
834
+ breadcrumbs: [
835
+ { text: "Reader", href: request.baseUrl },
836
+ { text: "Search" },
837
+ ],
838
+ });
839
+ }
840
+ throw error;
841
+ }
799
842
  }
800
843
 
801
844
  /**
@@ -171,12 +171,14 @@ export async function fetchAndParseFeed(url, options = {}) {
171
171
  // Fetch and parse the discovered feed
172
172
  const feedResult = await fetchFeed(fallbackFeed.url, options);
173
173
  if (!feedResult.notModified) {
174
+ const fallbackType = detectFeedType(feedResult.content, feedResult.contentType);
174
175
  const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
175
176
  contentType: feedResult.contentType,
176
177
  });
177
178
  return {
178
179
  ...feedResult,
179
180
  ...parsed,
181
+ feedType: fallbackType,
180
182
  hub: feedResult.hub || parsed._hub,
181
183
  discoveredFrom: url,
182
184
  };
@@ -194,6 +196,7 @@ export async function fetchAndParseFeed(url, options = {}) {
194
196
  return {
195
197
  ...result,
196
198
  ...parsed,
199
+ feedType: feedType,
197
200
  hub: result.hub || parsed._hub,
198
201
  };
199
202
  }
@@ -132,13 +132,16 @@ export async function processFeed(application, feed) {
132
132
  lastModified: parsed.lastModified,
133
133
  };
134
134
 
135
- // Update feed title/photo if discovered
135
+ // Update feed title/photo/feedType if discovered
136
136
  if (parsed.name && !feed.title) {
137
137
  updateData.title = parsed.name;
138
138
  }
139
139
  if (parsed.photo && !feed.photo) {
140
140
  updateData.photo = parsed.photo;
141
141
  }
142
+ if (parsed.feedType && !feed.feedType) {
143
+ updateData.feedType = parsed.feedType;
144
+ }
142
145
 
143
146
  await updateFeedAfterFetch(
144
147
  application,
@@ -16,6 +16,73 @@ function getCollection(application) {
16
16
  return application.collections.get("microsub_feeds");
17
17
  }
18
18
 
19
+ /**
20
+ * Normalize a feed URL for duplicate comparison.
21
+ * Strips trailing slashes, normalizes protocol to https, lowercases hostname.
22
+ * @param {string} url - Feed URL
23
+ * @returns {string} Normalized URL
24
+ */
25
+ export function normalizeUrl(url) {
26
+ try {
27
+ const parsed = new URL(url);
28
+ // Normalize protocol to https
29
+ parsed.protocol = "https:";
30
+ // Lowercase hostname
31
+ parsed.hostname = parsed.hostname.toLowerCase();
32
+ // Remove trailing slash from path (but keep "/" for root)
33
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
34
+ parsed.pathname = parsed.pathname.slice(0, -1);
35
+ }
36
+ return parsed.href;
37
+ } catch {
38
+ return url;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Find an existing feed across ALL channels by normalized URL
44
+ * @param {object} application - Indiekit application
45
+ * @param {string} url - Feed URL to check
46
+ * @returns {Promise<object|null>} Existing feed with channel info, or null
47
+ */
48
+ export async function findFeedAcrossChannels(application, url) {
49
+ const collection = getCollection(application);
50
+ const normalized = normalizeUrl(url);
51
+
52
+ // Get all feeds and check normalized URLs
53
+ // We check a few common URL variants directly for efficiency
54
+ const variants = new Set();
55
+ variants.add(url);
56
+ variants.add(normalized);
57
+ // Also try with/without trailing slash
58
+ if (url.endsWith("/")) {
59
+ variants.add(url.slice(0, -1));
60
+ } else {
61
+ variants.add(url + "/");
62
+ }
63
+ // Try http/https variants
64
+ if (url.startsWith("https://")) {
65
+ variants.add(url.replace("https://", "http://"));
66
+ } else if (url.startsWith("http://")) {
67
+ variants.add(url.replace("http://", "https://"));
68
+ }
69
+
70
+ const existing = await collection.findOne({
71
+ url: { $in: [...variants] },
72
+ });
73
+
74
+ if (!existing) return null;
75
+
76
+ // Look up the channel name for a useful error message
77
+ const channelsCollection = application.collections.get("microsub_channels");
78
+ const channel = await channelsCollection.findOne({ _id: existing.channelId });
79
+
80
+ return {
81
+ feed: existing,
82
+ channelName: channel?.name || "unknown channel",
83
+ };
84
+ }
85
+
19
86
  /**
20
87
  * Create a new feed subscription
21
88
  * @param {object} application - Indiekit application
@@ -32,12 +99,24 @@ export async function createFeed(
32
99
  ) {
33
100
  const collection = getCollection(application);
34
101
 
35
- // Check if feed already exists in channel
102
+ // Check if feed already exists in this channel (exact match)
36
103
  const existing = await collection.findOne({ channelId, url });
37
104
  if (existing) {
38
105
  return existing;
39
106
  }
40
107
 
108
+ // Check for duplicate across ALL channels (normalized URL)
109
+ const duplicate = await findFeedAcrossChannels(application, url);
110
+ if (duplicate) {
111
+ const error = new Error(
112
+ `Feed already exists in channel "${duplicate.channelName}"`,
113
+ );
114
+ error.code = "DUPLICATE_FEED";
115
+ error.existingFeed = duplicate.feed;
116
+ error.channelName = duplicate.channelName;
117
+ throw error;
118
+ }
119
+
41
120
  const feed = {
42
121
  channelId,
43
122
  url,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.42",
3
+ "version": "1.0.44",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/feeds.njk CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  <h2>{{ __("microsub.feeds.title") }}</h2>
12
12
 
13
+ {% if error %}
14
+ <div class="notice notice--error" role="alert">
15
+ {{ error }}
16
+ </div>
17
+ {% endif %}
18
+
13
19
  {% if feeds.length > 0 %}
14
20
  <div class="feeds__list">
15
21
  {% for feed in feeds %}
@@ -27,6 +33,9 @@
27
33
  <div class="feeds__details">
28
34
  <span class="feeds__name">
29
35
  {{ feed.title or feed.url }}
36
+ {% if feed.feedType %}
37
+ <span class="badge badge--offset badge--small" title="Feed format">{{ feed.feedType | upper }}</span>
38
+ {% endif %}
30
39
  {% if feed.status == 'error' %}
31
40
  <span class="badge badge--red">Error</span>
32
41
  {% elif feed.status == 'active' %}