diff --git a/lib/controllers/follow.js b/lib/controllers/follow.js index ef79e7d..9c9e5be 100644 --- a/lib/controllers/follow.js +++ b/lib/controllers/follow.js @@ -5,6 +5,7 @@ import { IndiekitError } from "@indiekit/error"; +import { detectCapabilities } from "../feeds/capabilities.js"; import { refreshFeedNow } from "../polling/scheduler.js"; import { getChannel } from "../storage/channels.js"; import { @@ -12,6 +13,7 @@ import { deleteFeed, getFeedByUrl, getFeedsForChannel, + updateFeed, } from "../storage/feeds.js"; import { getUserId } from "../utils/auth.js"; import { notifyBlogroll } from "../utils/blogroll-notify.js"; @@ -79,6 +81,15 @@ export async function follow(request, response) { 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, diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index f6e2d43..4cc1b7b 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -347,6 +347,20 @@ function ensureString(value) { return String(value); } +/** + * Detect the protocol of a URL for auto-syndication targeting + * @param {string} url - URL to classify + * @returns {string} "atmosphere" | "fediverse" | "web" + */ +function detectProtocol(url) { + if (!url || typeof url !== "string") return "web"; + const lower = url.toLowerCase(); + if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere"; + if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") || + lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse"; + return "web"; +} + /** * Fetch syndication targets from Micropub config * @param {object} application - Indiekit application @@ -406,6 +420,20 @@ export async function compose(request, response) { ? await getSyndicationTargets(application, token) : []; + // Auto-select syndication target based on interaction URL protocol + const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost); + if (interactionUrl && syndicationTargets.length > 0) { + const protocol = detectProtocol(interactionUrl); + for (const target of syndicationTargets) { + const targetId = (target.uid || target.name || "").toLowerCase(); + if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) { + target.checked = true; + } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) { + target.checked = true; + } + } + } + response.render("compose", { title: request.__("microsub.compose.title"), replyTo: ensureString(replyTo || reply), diff --git a/lib/feeds/capabilities.js b/lib/feeds/capabilities.js new file mode 100644 index 0000000..adf56dd --- /dev/null +++ b/lib/feeds/capabilities.js @@ -0,0 +1,204 @@ +/** + * Source capability detection + * Detects what a feed source supports (webmention, micropub, platform API) + * @module feeds/capabilities + */ + +/** + * Known Fediverse domain patterns + */ +const FEDIVERSE_PATTERNS = [ + "mastodon.", + "mstdn.", + "fosstodon.", + "pleroma.", + "misskey.", + "pixelfed.", + "fediverse", +]; + +/** + * Detect the capabilities of a feed source + * @param {string} feedUrl - The feed URL + * @param {string} [siteUrl] - Optional site homepage URL (if different from feed) + * @returns {Promise} Capability profile + */ +export async function detectCapabilities(feedUrl, siteUrl) { + const result = { + source_type: "publication", + webmention: null, + micropub: null, + platform_api: null, + author_mode: "single", + interactions: [], + detected_at: new Date().toISOString(), + }; + + try { + // 1. Pattern-match feed URL for known platforms + const platformMatch = matchPlatform(feedUrl); + if (platformMatch) { + result.source_type = platformMatch.type; + result.platform_api = platformMatch.api; + result.interactions = platformMatch.interactions; + return result; + } + + // 2. Fetch site homepage and check for rel links + const homepageUrl = siteUrl || deriveHomepage(feedUrl); + if (homepageUrl) { + const endpoints = await discoverEndpoints(homepageUrl); + result.webmention = endpoints.webmention; + result.micropub = endpoints.micropub; + + if (endpoints.webmention && endpoints.micropub) { + result.source_type = "indieweb"; + result.interactions = ["reply", "like", "repost", "bookmark"]; + } else if (endpoints.webmention) { + result.source_type = "web"; + result.interactions = ["reply"]; + } + } + } catch (error) { + console.error( + `[Microsub] Capability detection failed for ${feedUrl}:`, + error.message, + ); + } + + return result; +} + +/** + * Pattern-match a feed URL against known platforms + * @param {string} url - Feed URL + * @returns {object|null} Platform match or null + */ +function matchPlatform(url) { + const lower = url.toLowerCase(); + + // Bluesky + if (lower.includes("bsky.app") || lower.includes("bluesky")) { + return { + type: "bluesky", + api: { type: "atproto", authed: false }, + interactions: ["reply", "like", "repost"], + }; + } + + // Mastodon / Fediverse RSS (e.g., mastodon.social/@user.rss) + if (FEDIVERSE_PATTERNS.some((pattern) => lower.includes(pattern))) { + return { + type: "mastodon", + api: { type: "activitypub", authed: false }, + interactions: ["reply", "like", "repost"], + }; + } + + // WordPress (common RSS patterns) + if (lower.includes("/wp-json/") || lower.includes("/feed/")) { + // Could be WordPress but also others — don't match too broadly + // Only match the /wp-json/ pattern which is WordPress-specific + if (lower.includes("/wp-json/")) { + return { + type: "wordpress", + api: { type: "wp-rest", authed: false }, + interactions: ["reply"], + }; + } + } + + return null; +} + +/** + * Derive a homepage URL from a feed URL + * @param {string} feedUrl - Feed URL + * @returns {string|null} Homepage URL + */ +function deriveHomepage(feedUrl) { + try { + const url = new URL(feedUrl); + return `${url.protocol}//${url.host}/`; + } catch { + return null; + } +} + +/** + * Discover webmention and micropub endpoints from a URL + * @param {string} url - URL to check for endpoint links + * @returns {Promise} Discovered endpoints + */ +async function discoverEndpoints(url) { + const endpoints = { + webmention: null, + micropub: null, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "text/html", + "User-Agent": "Microsub/1.0 (+https://indieweb.org/Microsub)", + }, + redirect: "follow", + }); + + if (!response.ok) return endpoints; + + // Check Link headers first + const linkHeader = response.headers.get("link"); + if (linkHeader) { + const wmMatch = linkHeader.match( + /<([^>]+)>;\s*rel="?webmention"?/i, + ); + if (wmMatch) endpoints.webmention = wmMatch[1]; + + const mpMatch = linkHeader.match( + /<([^>]+)>;\s*rel="?micropub"?/i, + ); + if (mpMatch) endpoints.micropub = mpMatch[1]; + } + + // If not found in headers, check HTML + if (!endpoints.webmention || !endpoints.micropub) { + const html = await response.text(); + + if (!endpoints.webmention) { + const wmHtml = html.match( + /]+rel="?webmention"?[^>]+href="([^"]+)"/i, + ) || + html.match( + /]+href="([^"]+)"[^>]+rel="?webmention"?/i, + ); + if (wmHtml) endpoints.webmention = wmHtml[1]; + } + + if (!endpoints.micropub) { + const mpHtml = html.match( + /]+rel="?micropub"?[^>]+href="([^"]+)"/i, + ) || + html.match( + /]+href="([^"]+)"[^>]+rel="?micropub"?/i, + ); + if (mpHtml) endpoints.micropub = mpHtml[1]; + } + } + } catch (error) { + if (error.name !== "AbortError") { + console.debug( + `[Microsub] Endpoint discovery failed for ${url}:`, + error.message, + ); + } + } finally { + clearTimeout(timeout); + } + + return endpoints; +} diff --git a/lib/polling/processor.js b/lib/polling/processor.js index 32a480e..26bab3e 100644 --- a/lib/polling/processor.js +++ b/lib/polling/processor.js @@ -4,9 +4,11 @@ */ import { getRedisClient, publishEvent } from "../cache/redis.js"; +import { detectCapabilities } from "../feeds/capabilities.js"; import { fetchAndParseFeed } from "../feeds/fetcher.js"; import { getChannel } from "../storage/channels.js"; import { + updateFeed, updateFeedAfterFetch, updateFeedStatus, updateFeedWebsub, @@ -82,6 +84,15 @@ export async function processFeed(application, feed) { item._source.name = feed.title || parsed.name; } + // Attach source_type from feed capabilities (for protocol indicators) + // Falls back to URL-based inference when capabilities haven't been detected yet + item._source = item._source || {}; + if (feed.capabilities?.source_type) { + item._source.source_type = feed.capabilities.source_type; + } else { + item._source.source_type = inferSourceType(feed.url); + } + // Store the item const stored = await addItem(application, { channelId: feed.channelId, @@ -177,6 +188,20 @@ export async function processFeed(application, feed) { success: true, itemCount: parsed.items?.length || 0, }); + + // Detect source capabilities on first successful fetch (if not yet detected) + if (!feed.capabilities) { + detectCapabilities(feed.url) + .then((capabilities) => { + updateFeed(application, feed._id, { capabilities }).catch(() => {}); + }) + .catch((error) => { + console.debug( + `[Microsub] Capability detection skipped for ${feed.url}:`, + error.message, + ); + }); + } } catch (error) { result.error = error.message; @@ -208,6 +233,21 @@ export async function processFeed(application, feed) { return result; } +/** + * Infer source type from feed URL when capabilities haven't been detected yet + * @param {string} url - Feed URL + * @returns {string} Source type + */ +function inferSourceType(url) { + if (!url) return "web"; + const lower = url.toLowerCase(); + if (lower.includes("bsky.app") || lower.includes("bluesky")) return "bluesky"; + if (lower.includes("mastodon.") || lower.includes("mstdn.") || + lower.includes("fosstodon.") || lower.includes("pleroma.") || + lower.includes("misskey.") || lower.includes("pixelfed.")) return "mastodon"; + return "web"; +} + /** * Check if an item passes channel filters * @param {object} item - Feed item diff --git a/package.json b/package.json index edb274b..1a1fb9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-microsub", - "version": "1.0.32", + "version": "1.0.33", "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", "keywords": [ "indiekit", diff --git a/views/partials/item-card.njk b/views/partials/item-card.njk index 838b73e..a529c27 100644 --- a/views/partials/item-card.njk +++ b/views/partials/item-card.njk @@ -63,13 +63,26 @@ {% endif %}
{{ item.author.name or "Unknown" }} - {% if item._source and item._source.type === "activitypub" %} + {% if item._source %} - AP - {{ item.author.url | replace("https://", "") | replace("http://", "") }} + {# Protocol source indicator #} + {% set sourceUrl = item._source.url or item.author.url or "" %} + {% set sourceType = item._source.source_type or item._source.type %} + {% if sourceType == "activitypub" or sourceType == "mastodon" or ("mastodon." in sourceUrl) or ("mstdn." in sourceUrl) or ("fosstodon." in sourceUrl) or ("pleroma." in sourceUrl) or ("misskey." in sourceUrl) %} + + + + {% elif sourceType == "bluesky" or ("bsky.app" in sourceUrl) or ("bluesky" in sourceUrl) %} + + + + {% else %} + + + + {% endif %} + {{ item._source.name or item._source.url }} - {% elif item._source %} - {{ item._source.name or item._source.url }} {% elif item.author.url %} {{ item.author.url | replace("https://", "") | replace("http://", "") }} {% endif %}