@rmdes/indiekit-endpoint-microsub 1.0.43 → 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.
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.43",
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' %}