feat: auto-follow bookmarked URLs as Microsub feed subscriptions

When a Micropub bookmark-of post is created (HTTP 201/202), intercept
via contentNegotiationRoutes and subscribe the bookmarked URL as a feed
in the Microsub reader. Mirrors the blogroll bookmark-import pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-12 08:31:27 +01:00
parent e48335da2c
commit 6b3a280ead
2 changed files with 161 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import express from "express"; import express from "express";
import { importBookmarkAsFollow } from "./lib/bookmark-import.js";
import { microsubController } from "./lib/controllers/microsub.js"; import { microsubController } from "./lib/controllers/microsub.js";
import { opmlController } from "./lib/controllers/opml.js"; import { opmlController } from "./lib/controllers/opml.js";
import { readerController } from "./lib/controllers/reader.js"; import { readerController } from "./lib/controllers/reader.js";
@@ -14,6 +15,7 @@ import {
cleanupStaleItems, cleanupStaleItems,
createIndexes, createIndexes,
} from "./lib/storage/items.js"; } from "./lib/storage/items.js";
import { getUserId } from "./lib/utils/auth.js";
import { webmentionReceiver } from "./lib/webmention/receiver.js"; import { webmentionReceiver } from "./lib/webmention/receiver.js";
import { websubHandler } from "./lib/websub/handler.js"; import { websubHandler } from "./lib/websub/handler.js";
@@ -23,6 +25,43 @@ const defaults = {
const router = express.Router(); const router = express.Router();
const readerRouter = 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 { export default class MicrosubEndpoint {
name = "Microsub endpoint"; 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) * Microsub API and reader UI routes (authenticated)
* @returns {import("express").Router} Express router * @returns {import("express").Router} Express router

113
lib/bookmark-import.js Normal file
View File

@@ -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 };
}