diff --git a/index.js b/index.js index 127df93..b200f2f 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import express from "express"; +import { importBookmarkAsFollow } from "./lib/bookmark-import.js"; import { microsubController } from "./lib/controllers/microsub.js"; import { opmlController } from "./lib/controllers/opml.js"; import { readerController } from "./lib/controllers/reader.js"; @@ -14,6 +15,7 @@ import { cleanupStaleItems, createIndexes, } from "./lib/storage/items.js"; +import { getUserId } from "./lib/utils/auth.js"; import { webmentionReceiver } from "./lib/webmention/receiver.js"; import { websubHandler } from "./lib/websub/handler.js"; @@ -23,6 +25,43 @@ const defaults = { const router = express.Router(); const readerRouter = express.Router(); +const bookmarkHookRouter = express.Router(); +bookmarkHookRouter.use((request, response, next) => { + response.on("finish", () => { + if ( + request.method !== "POST" || + (response.statusCode !== 201 && response.statusCode !== 202) + ) { + return; + } + + const action = + request.query?.action || request.body?.action || "create"; + if (action !== "create") return; + + const bookmarkOf = + request.body?.["bookmark-of"] || + request.body?.properties?.["bookmark-of"]?.[0]; + if (!bookmarkOf) return; + + const rawCategory = + request.body?.category || + request.body?.properties?.category; + const category = Array.isArray(rawCategory) + ? rawCategory[0] || "bookmarks" + : rawCategory || "bookmarks"; + + const { application } = request.app.locals; + const userId = getUserId(request); + importBookmarkAsFollow(application, bookmarkOf, category, userId).catch( + (err) => + console.warn("[Microsub] bookmark-import failed:", err.message), + ); + }); + + next(); +}); + export default class MicrosubEndpoint { name = "Microsub endpoint"; @@ -68,6 +107,15 @@ export default class MicrosubEndpoint { }; } + /** + * Middleware hook registered on the Micropub route to intercept bookmark + * posts and auto-follow them as Microsub feed subscriptions. + * @returns {import("express").Router} Express router + */ + get contentNegotiationRoutes() { + return bookmarkHookRouter; + } + /** * Microsub API and reader UI routes (authenticated) * @returns {import("express").Router} Express router diff --git a/lib/bookmark-import.js b/lib/bookmark-import.js new file mode 100644 index 0000000..848c88f --- /dev/null +++ b/lib/bookmark-import.js @@ -0,0 +1,113 @@ +/** + * Bookmark-to-microsub import + * + * When a Micropub bookmark-of post is created, automatically follow that URL + * as a feed in the Microsub reader. Mirrors the blogroll bookmark-import pattern. + * @module bookmark-import + */ + +import { detectCapabilities } from "./feeds/capabilities.js"; +import { refreshFeedNow } from "./polling/scheduler.js"; +import { getChannels } from "./storage/channels.js"; +import { createFeed, findFeedAcrossChannels } from "./storage/feeds.js"; + +const BOOKMARKS_CHANNEL_NAME = "Bookmarks"; + +/** + * Follow a bookmarked URL as a Microsub feed subscription. + * + * Finds the best matching channel (by category name → "Bookmarks" → first + * non-special channel) and creates a feed subscription if one does not + * already exist. + * + * @param {object} application - Indiekit application context + * @param {string|string[]} bookmarkUrl - The bookmarked URL + * @param {string} [category="bookmarks"] - Micropub category hint for channel selection + * @param {string} [userId="default"] - User ID for channel lookup + */ +export async function importBookmarkAsFollow( + application, + bookmarkUrl, + category = "bookmarks", + userId = "default", +) { + const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl; + + try { + new URL(url); + } catch { + console.warn(`[Microsub] bookmark-import: invalid URL: ${url}`); + return { error: `Invalid bookmark URL: ${url}` }; + } + + if (!application.collections?.has("microsub_channels")) { + console.warn("[Microsub] bookmark-import: microsub collections not available"); + return { error: "microsub not initialised" }; + } + + // Check if already followed in any channel + const existing = await findFeedAcrossChannels(application, url); + if (existing) { + console.log(`[Microsub] bookmark-import: ${url} already followed`); + return { alreadyExists: true, url }; + } + + // Find a suitable channel — category match > "Bookmarks" > first non-special + const channels = await getChannels(application, userId); + const categoryLower = (category || "").toLowerCase(); + + const targetChannel = + channels.find((ch) => ch.name?.toLowerCase() === categoryLower) || + channels.find( + (ch) => ch.name?.toLowerCase() === BOOKMARKS_CHANNEL_NAME.toLowerCase(), + ) || + channels.find( + (ch) => ch.name !== "Notifications" && ch.name !== "ActivityPub", + ) || + channels[0]; + + if (!targetChannel) { + console.warn("[Microsub] bookmark-import: no channels available"); + return { error: "no channels available" }; + } + + // Create feed subscription + let feed; + try { + feed = await createFeed(application, { + channelId: targetChannel._id, + url, + title: undefined, + photo: undefined, + }); + } catch (error) { + if (error.code === "DUPLICATE_FEED") { + console.log(`[Microsub] bookmark-import: feed already exists for ${url}`); + return { alreadyExists: true, url }; + } + throw error; + } + + // Fire-and-forget: fetch and detect capabilities + refreshFeedNow(application, feed._id).catch((error) => { + console.error( + `[Microsub] bookmark-import: error fetching ${url}:`, + error.message, + ); + }); + detectCapabilities(url) + .then((_capabilities) => { + // capabilities are stored by refreshFeedNow/processor; nothing needed here + }) + .catch((error) => { + console.error( + `[Microsub] bookmark-import: capability detection error for ${url}:`, + error.message, + ); + }); + + console.log( + `[Microsub] bookmark-import: added ${url} to channel "${targetChannel.name}"`, + ); + return { added: 1, url, channel: targetChannel.name }; +}