mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
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:
48
index.js
48
index.js
@@ -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
113
lib/bookmark-import.js
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user