diff --git a/index.js b/index.js index 6b4ec5d..22c82be 100644 --- a/index.js +++ b/index.js @@ -7,12 +7,71 @@ import { blogsController } from "./lib/controllers/blogs.js"; import { sourcesController } from "./lib/controllers/sources.js"; import { apiController } from "./lib/controllers/api.js"; import { startSync, stopSync } from "./lib/sync/scheduler.js"; +import { importBookmarkUrl } from "./lib/bookmark-import.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const protectedRouter = express.Router(); const publicRouter = express.Router(); +// Global hook router: intercepts POST requests site-wide to detect micropub +// bookmark creations and auto-import the bookmarked site into the blogroll. +// Mounted at "/" via contentNegotiationRoutes (runs before auth middleware). +// +// NOTE: When the Microsub plugin is installed it acts as the single source of +// truth for bookmarks — it creates the feed subscription AND notifies the +// blogroll via notifyBlogroll(). This hook therefore skips processing if +// Microsub is available, acting only as a standalone fallback. +const bookmarkHookRouter = express.Router(); +bookmarkHookRouter.use((request, response, next) => { + response.on("finish", () => { + // Only act on successful POST creates (201 Created / 202 Accepted) + if ( + request.method !== "POST" || + (response.statusCode !== 201 && response.statusCode !== 202) + ) { + return; + } + + // Ignore non-create actions (update, delete, undelete) + const action = + request.query?.action || request.body?.action || "create"; + if (action !== "create") return; + + // bookmark-of may be a top-level field (form-encoded / JF2 JSON) + // or nested inside properties (MF2 JSON format) + const bookmarkOf = + request.body?.["bookmark-of"] || + request.body?.properties?.["bookmark-of"]?.[0]; + if (!bookmarkOf) return; + + const { application } = request.app.locals; + + // Microsub plugin is installed → it will handle this bookmark and notify + // the blogroll. Skip direct import to avoid duplicate entries. + if (application.collections?.has("microsub_channels")) { + return; + } + + // Extract category from any micropub body format: + // form-encoded: category=tech or category[]=tech&category[]=web + // JF2 JSON: { "category": ["tech", "web"] } + // MF2 JSON: { "properties": { "category": ["tech"] } } + const rawCategory = + request.body?.category || + request.body?.properties?.category; + const category = Array.isArray(rawCategory) + ? rawCategory[0] || "bookmarks" + : rawCategory || "bookmarks"; + + importBookmarkUrl(application, bookmarkOf, category).catch((err) => + console.warn("[Blogroll] bookmark-import failed:", err.message) + ); + }); + + next(); +}); + const defaults = { mountPath: "/blogrollapi", syncInterval: 3600000, // 1 hour @@ -54,6 +113,14 @@ export default class BlogrollEndpoint { }; } + /** + * Global middleware (mounted at "/") — intercepts micropub bookmark creations. + * Uses res.on("finish") so it never interferes with the request lifecycle. + */ + get contentNegotiationRoutes() { + return bookmarkHookRouter; + } + /** * Protected routes (require authentication) * Admin dashboard and management @@ -149,4 +216,4 @@ export default class BlogrollEndpoint { destroy() { stopSync(); } -} +} \ No newline at end of file diff --git a/lib/bookmark-import.js b/lib/bookmark-import.js new file mode 100644 index 0000000..429d6cd --- /dev/null +++ b/lib/bookmark-import.js @@ -0,0 +1,91 @@ +/** + * Bookmark → blogroll import + * Called when a micropub bookmark post is created. + * Discovers feeds for the bookmarked site's origin and adds them to the blogroll. + * @module lib/bookmark-import + */ + +import { discoverFeeds } from "./utils/feed-discovery.js"; +import { upsertBlog } from "./storage/blogs.js"; + +/** + * Import a bookmarked URL's site into the blogroll. + * Extracts the origin URL, discovers feeds, and upserts the first feed as a blog entry. + * + * @param {object} application - Indiekit application object + * @param {string} bookmarkUrl - The URL that was bookmarked (bookmark-of value) + * @param {string} [category="bookmarks"] - Category to assign in the blogroll + * @returns {Promise} Result { added, alreadyExists, noFeeds, error } + */ +export async function importBookmarkUrl(application, bookmarkUrl, category = "bookmarks") { + // Normalise: bookmark-of may be an array in some micropub clients + const url = Array.isArray(bookmarkUrl) ? bookmarkUrl[0] : bookmarkUrl; + + let siteUrl; + try { + siteUrl = new URL(url).origin; + } catch { + return { error: `Invalid bookmark URL: ${url}` }; + } + + // Guard: blogroll DB must be available + if (typeof application.getBlogrollDb !== "function") { + console.warn("[Blogroll] bookmark-import: getBlogrollDb not available"); + return { error: "blogroll not initialised" }; + } + + const db = application.getBlogrollDb(); + + // Check if any active blog with this siteUrl is already in the blogroll + const existing = await db.collection("blogrollBlogs").findOne({ + siteUrl, + status: { $ne: "deleted" }, + }); + + if (existing) { + // If the category differs, update it (tag changed on the bookmark post) + if (existing.category !== category) { + await db.collection("blogrollBlogs").updateOne( + { _id: existing._id }, + { $set: { category, updatedAt: new Date().toISOString() } }, + ); + console.log( + `[Blogroll] bookmark-import: updated category for "${existing.title}" → "${category}"`, + ); + return { updated: true, siteUrl }; + } + console.log( + `[Blogroll] bookmark-import: ${siteUrl} already in blogroll ("${existing.title}")`, + ); + return { alreadyExists: true, siteUrl }; + } + + // Discover feeds from the origin + const discovery = await discoverFeeds(siteUrl); + + if (!discovery.success || discovery.feeds.length === 0) { + console.log(`[Blogroll] bookmark-import: no feeds found for ${siteUrl}`); + return { noFeeds: true, siteUrl }; + } + + // Add the first discovered feed + const feed = discovery.feeds[0]; + const result = await upsertBlog(application, { + title: discovery.pageTitle || siteUrl, + feedUrl: feed.url, + siteUrl, + feedType: feed.type || "rss", + category, + source: "bookmark", + sourceId: null, + status: "active", + }); + + if (result.upserted) { + console.log( + `[Blogroll] bookmark-import: added ${feed.url} ("${discovery.pageTitle || siteUrl}") → category "${category}"` + ); + } + + return { added: result.upserted ? 1 : 0, siteUrl }; +} \ No newline at end of file