Files
indiekit-endpoint-microsub/lib/controllers/follow.js
Ricardo 5037ff3d8f 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
2026-03-10 14:27:01 +01:00

168 lines
5.0 KiB
JavaScript

/**
* Follow/unfollow controller
* @module controllers/follow
*/
import { IndiekitError } from "@indiekit/error";
import { detectCapabilities } from "../feeds/capabilities.js";
import { refreshFeedNow } from "../polling/scheduler.js";
import { getChannel } from "../storage/channels.js";
import {
createFeed,
deleteFeed,
getFeedByUrl,
getFeedsForChannel,
updateFeed,
} from "../storage/feeds.js";
import { getUserId } from "../utils/auth.js";
import { notifyBlogroll } from "../utils/blogroll-notify.js";
import { createFeedResponse } from "../utils/jf2.js";
import { validateChannel, validateUrl } from "../utils/validation.js";
import {
unsubscribe as websubUnsubscribe,
getCallbackUrl,
} from "../websub/subscriber.js";
/**
* List followed feeds for a channel
* GET ?action=follow&channel=<uid>
* @param {object} request - Express request
* @param {object} response - Express response
*/
export async function list(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { channel } = request.query;
validateChannel(channel);
const channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
throw new IndiekitError("Channel not found", { status: 404 });
}
const feeds = await getFeedsForChannel(application, channelDocument._id);
const items = feeds.map((feed) => createFeedResponse(feed));
response.json({ items });
}
/**
* Follow a feed URL
* POST ?action=follow
* @param {object} request - Express request
* @param {object} response - Express response
*/
export async function follow(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { channel, url } = request.body;
validateChannel(channel);
validateUrl(url);
const channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
throw new IndiekitError("Channel not found", { status: 404 });
}
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
let feed;
try {
feed = await createFeed(application, {
channelId: channelDocument._id,
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)
// This will also discover and subscribe to WebSub hubs
refreshFeedNow(application, feed._id).catch((error) => {
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
});
// Detect source capabilities (fire-and-forget)
detectCapabilities(url).then((capabilities) => {
updateFeed(application, feed._id, { capabilities }).catch((error) => {
console.error(`[Microsub] Capability storage error:`, error.message);
});
}).catch((error) => {
console.error(`[Microsub] Capability detection error for ${url}:`, error.message);
});
// Notify blogroll plugin (fire-and-forget)
notifyBlogroll(application, "follow", {
url,
title: feed.title,
channelName: channelDocument.name,
feedId: feed._id.toString(),
channelId: channelDocument._id.toString(),
}).catch((error) => {
console.error(`[Microsub] Blogroll notify error:`, error.message);
});
response.status(201).json(createFeedResponse(feed));
}
/**
* Unfollow a feed URL
* POST ?action=unfollow
* @param {object} request - Express request
* @param {object} response - Express response
*/
export async function unfollow(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { channel, url } = request.body;
validateChannel(channel);
validateUrl(url);
const channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
throw new IndiekitError("Channel not found", { status: 404 });
}
// Get feed before deletion to check for WebSub subscription
const feed = await getFeedByUrl(application, channelDocument._id, url);
// Unsubscribe from WebSub hub if active
if (feed?.websub?.hub) {
const baseUrl = application.url;
if (baseUrl) {
const callbackUrl = getCallbackUrl(baseUrl, feed._id.toString());
websubUnsubscribe(application, feed, callbackUrl).catch((error) => {
console.error(
`[Microsub] WebSub unsubscribe error for ${url}:`,
error.message,
);
});
}
}
const deleted = await deleteFeed(application, channelDocument._id, url);
if (!deleted) {
throw new IndiekitError("Feed not found", { status: 404 });
}
// Notify blogroll plugin (fire-and-forget)
notifyBlogroll(application, "unfollow", { url }).catch((error) => {
console.error(`[Microsub] Blogroll notify error:`, error.message);
});
response.json({ result: "ok" });
}
export const followController = { list, follow, unfollow };