@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.
- package/lib/controllers/follow.js +18 -7
- package/lib/controllers/reader.js +67 -24
- package/lib/feeds/fetcher.js +3 -0
- package/lib/polling/processor.js +4 -1
- package/lib/storage/feeds.js +80 -1
- package/package.json +1 -1
- package/views/feeds.njk +9 -0
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/lib/feeds/fetcher.js
CHANGED
|
@@ -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
|
}
|
package/lib/polling/processor.js
CHANGED
|
@@ -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,
|
package/lib/storage/feeds.js
CHANGED
|
@@ -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
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' %}
|