feat: add feed type indicator and cross-channel duplicate detection
- Store feedType (rss/atom/jsonfeed/hfeed) on feed documents during polling - Display feed type badge in reader feeds view - Detect duplicate feeds across all channels using URL normalization (trailing slashes, http/https variants) - Show clear error message when subscribing to a feed that already exists - Handle duplicates in both reader UI and Microsub API (HTTP 409) - Bump version to 1.0.44 Confab-Link: http://localhost:8080/sessions/f1d9ff88-e037-4d6e-b595-ed6d9e00898e
This commit is contained in:
@@ -67,13 +67,24 @@ export async function follow(request, response) {
|
|||||||
throw new IndiekitError("Channel not found", { status: 404 });
|
throw new IndiekitError("Channel not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create feed subscription
|
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
|
||||||
const feed = await createFeed(application, {
|
let feed;
|
||||||
channelId: channelDocument._id,
|
try {
|
||||||
url,
|
feed = await createFeed(application, {
|
||||||
title: undefined, // Will be populated on first fetch
|
channelId: channelDocument._id,
|
||||||
photo: undefined,
|
url,
|
||||||
});
|
title: undefined, // Will be populated on first fetch
|
||||||
|
photo: undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "DUPLICATE_FEED") {
|
||||||
|
throw new IndiekitError(
|
||||||
|
`Feed already exists in channel "${error.channelName}"`,
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger immediate fetch in background (don't await)
|
// Trigger immediate fetch in background (don't await)
|
||||||
// This will also discover and subscribe to WebSub hubs
|
// This will also discover and subscribe to WebSub hubs
|
||||||
|
|||||||
+67
-24
@@ -319,20 +319,43 @@ export async function addFeed(request, response) {
|
|||||||
return response.status(404).render("404");
|
return response.status(404).render("404");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create feed subscription
|
try {
|
||||||
const feed = await createFeed(application, {
|
// Create feed subscription (throws DUPLICATE_FEED if already exists)
|
||||||
channelId: channelDocument._id,
|
const feed = await createFeed(application, {
|
||||||
url,
|
channelId: channelDocument._id,
|
||||||
title: undefined,
|
url,
|
||||||
photo: undefined,
|
title: undefined,
|
||||||
});
|
photo: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// Trigger immediate fetch in background
|
// Trigger immediate fetch in background
|
||||||
refreshFeedNow(application, feed._id).catch((error) => {
|
refreshFeedNow(application, feed._id).catch((error) => {
|
||||||
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "DUPLICATE_FEED") {
|
||||||
|
// Re-render feeds page with error message
|
||||||
|
const feedList = await getFeedsForChannel(application, channelDocument._id);
|
||||||
|
return response.render("feeds", {
|
||||||
|
title: request.__("microsub.feeds.title"),
|
||||||
|
channel: channelDocument,
|
||||||
|
feeds: feedList,
|
||||||
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
|
error: `This feed already exists in channel "${error.channelName}"`,
|
||||||
|
breadcrumbs: [
|
||||||
|
{ text: "Reader", href: request.baseUrl },
|
||||||
|
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
||||||
|
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
||||||
|
{ text: "Feeds" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -782,20 +805,40 @@ export async function subscribe(request, response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create feed subscription
|
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
|
||||||
const feed = await createFeed(application, {
|
try {
|
||||||
channelId: channelDocument._id,
|
const feed = await createFeed(application, {
|
||||||
url,
|
channelId: channelDocument._id,
|
||||||
title: undefined,
|
url,
|
||||||
photo: undefined,
|
title: undefined,
|
||||||
});
|
photo: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// Trigger immediate fetch in background
|
// Trigger immediate fetch in background
|
||||||
refreshFeedNow(application, feed._id).catch((error) => {
|
refreshFeedNow(application, feed._id).catch((error) => {
|
||||||
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
|
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "DUPLICATE_FEED") {
|
||||||
|
const channelList = await getChannels(application, userId);
|
||||||
|
return response.render("search", {
|
||||||
|
title: request.__("microsub.search.title"),
|
||||||
|
channels: channelList,
|
||||||
|
query: url,
|
||||||
|
validationError: `This feed already exists in channel "${error.channelName}"`,
|
||||||
|
baseUrl: request.baseUrl,
|
||||||
|
readerBaseUrl: request.baseUrl,
|
||||||
|
activeView: "channels",
|
||||||
|
breadcrumbs: [
|
||||||
|
{ text: "Reader", href: request.baseUrl },
|
||||||
|
{ text: "Search" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -171,12 +171,14 @@ export async function fetchAndParseFeed(url, options = {}) {
|
|||||||
// Fetch and parse the discovered feed
|
// Fetch and parse the discovered feed
|
||||||
const feedResult = await fetchFeed(fallbackFeed.url, options);
|
const feedResult = await fetchFeed(fallbackFeed.url, options);
|
||||||
if (!feedResult.notModified) {
|
if (!feedResult.notModified) {
|
||||||
|
const fallbackType = detectFeedType(feedResult.content, feedResult.contentType);
|
||||||
const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
|
const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
|
||||||
contentType: feedResult.contentType,
|
contentType: feedResult.contentType,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...feedResult,
|
...feedResult,
|
||||||
...parsed,
|
...parsed,
|
||||||
|
feedType: fallbackType,
|
||||||
hub: feedResult.hub || parsed._hub,
|
hub: feedResult.hub || parsed._hub,
|
||||||
discoveredFrom: url,
|
discoveredFrom: url,
|
||||||
};
|
};
|
||||||
@@ -194,6 +196,7 @@ export async function fetchAndParseFeed(url, options = {}) {
|
|||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
...parsed,
|
...parsed,
|
||||||
|
feedType: feedType,
|
||||||
hub: result.hub || parsed._hub,
|
hub: result.hub || parsed._hub,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,13 +132,16 @@ export async function processFeed(application, feed) {
|
|||||||
lastModified: parsed.lastModified,
|
lastModified: parsed.lastModified,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update feed title/photo if discovered
|
// Update feed title/photo/feedType if discovered
|
||||||
if (parsed.name && !feed.title) {
|
if (parsed.name && !feed.title) {
|
||||||
updateData.title = parsed.name;
|
updateData.title = parsed.name;
|
||||||
}
|
}
|
||||||
if (parsed.photo && !feed.photo) {
|
if (parsed.photo && !feed.photo) {
|
||||||
updateData.photo = parsed.photo;
|
updateData.photo = parsed.photo;
|
||||||
}
|
}
|
||||||
|
if (parsed.feedType && !feed.feedType) {
|
||||||
|
updateData.feedType = parsed.feedType;
|
||||||
|
}
|
||||||
|
|
||||||
await updateFeedAfterFetch(
|
await updateFeedAfterFetch(
|
||||||
application,
|
application,
|
||||||
|
|||||||
+80
-1
@@ -16,6 +16,73 @@ function getCollection(application) {
|
|||||||
return application.collections.get("microsub_feeds");
|
return application.collections.get("microsub_feeds");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a feed URL for duplicate comparison.
|
||||||
|
* Strips trailing slashes, normalizes protocol to https, lowercases hostname.
|
||||||
|
* @param {string} url - Feed URL
|
||||||
|
* @returns {string} Normalized URL
|
||||||
|
*/
|
||||||
|
export function normalizeUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
// Normalize protocol to https
|
||||||
|
parsed.protocol = "https:";
|
||||||
|
// Lowercase hostname
|
||||||
|
parsed.hostname = parsed.hostname.toLowerCase();
|
||||||
|
// Remove trailing slash from path (but keep "/" for root)
|
||||||
|
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
|
||||||
|
parsed.pathname = parsed.pathname.slice(0, -1);
|
||||||
|
}
|
||||||
|
return parsed.href;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing feed across ALL channels by normalized URL
|
||||||
|
* @param {object} application - Indiekit application
|
||||||
|
* @param {string} url - Feed URL to check
|
||||||
|
* @returns {Promise<object|null>} Existing feed with channel info, or null
|
||||||
|
*/
|
||||||
|
export async function findFeedAcrossChannels(application, url) {
|
||||||
|
const collection = getCollection(application);
|
||||||
|
const normalized = normalizeUrl(url);
|
||||||
|
|
||||||
|
// Get all feeds and check normalized URLs
|
||||||
|
// We check a few common URL variants directly for efficiency
|
||||||
|
const variants = new Set();
|
||||||
|
variants.add(url);
|
||||||
|
variants.add(normalized);
|
||||||
|
// Also try with/without trailing slash
|
||||||
|
if (url.endsWith("/")) {
|
||||||
|
variants.add(url.slice(0, -1));
|
||||||
|
} else {
|
||||||
|
variants.add(url + "/");
|
||||||
|
}
|
||||||
|
// Try http/https variants
|
||||||
|
if (url.startsWith("https://")) {
|
||||||
|
variants.add(url.replace("https://", "http://"));
|
||||||
|
} else if (url.startsWith("http://")) {
|
||||||
|
variants.add(url.replace("http://", "https://"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await collection.findOne({
|
||||||
|
url: { $in: [...variants] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
// Look up the channel name for a useful error message
|
||||||
|
const channelsCollection = application.collections.get("microsub_channels");
|
||||||
|
const channel = await channelsCollection.findOne({ _id: existing.channelId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
feed: existing,
|
||||||
|
channelName: channel?.name || "unknown channel",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new feed subscription
|
* Create a new feed subscription
|
||||||
* @param {object} application - Indiekit application
|
* @param {object} application - Indiekit application
|
||||||
@@ -32,12 +99,24 @@ export async function createFeed(
|
|||||||
) {
|
) {
|
||||||
const collection = getCollection(application);
|
const collection = getCollection(application);
|
||||||
|
|
||||||
// Check if feed already exists in channel
|
// Check if feed already exists in this channel (exact match)
|
||||||
const existing = await collection.findOne({ channelId, url });
|
const existing = await collection.findOne({ channelId, url });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicate across ALL channels (normalized URL)
|
||||||
|
const duplicate = await findFeedAcrossChannels(application, url);
|
||||||
|
if (duplicate) {
|
||||||
|
const error = new Error(
|
||||||
|
`Feed already exists in channel "${duplicate.channelName}"`,
|
||||||
|
);
|
||||||
|
error.code = "DUPLICATE_FEED";
|
||||||
|
error.existingFeed = duplicate.feed;
|
||||||
|
error.channelName = duplicate.channelName;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const feed = {
|
const feed = {
|
||||||
channelId,
|
channelId,
|
||||||
url,
|
url,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
||||||
"version": "1.0.43",
|
"version": "1.0.44",
|
||||||
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
|
|
||||||
<h2>{{ __("microsub.feeds.title") }}</h2>
|
<h2>{{ __("microsub.feeds.title") }}</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notice notice--error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if feeds.length > 0 %}
|
{% if feeds.length > 0 %}
|
||||||
<div class="feeds__list">
|
<div class="feeds__list">
|
||||||
{% for feed in feeds %}
|
{% for feed in feeds %}
|
||||||
@@ -27,6 +33,9 @@
|
|||||||
<div class="feeds__details">
|
<div class="feeds__details">
|
||||||
<span class="feeds__name">
|
<span class="feeds__name">
|
||||||
{{ feed.title or feed.url }}
|
{{ feed.title or feed.url }}
|
||||||
|
{% if feed.feedType %}
|
||||||
|
<span class="badge badge--offset badge--small" title="Feed format">{{ feed.feedType | upper }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if feed.status == 'error' %}
|
{% if feed.status == 'error' %}
|
||||||
<span class="badge badge--red">Error</span>
|
<span class="badge badge--red">Error</span>
|
||||||
{% elif feed.status == 'active' %}
|
{% elif feed.status == 'active' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user