Files
svemagie 1bb80588fd feat: bookmark flow — tag→channel, notifyBlogroll, update tracking
- bookmark-import: find/create channel from first tag (no more fallback-only)
- bookmark-import: call notifyBlogroll() after creating feed so blogroll gets
  its entries from microsub, not independently
- bookmark-import: store micropubPostUrl on feed for update/delete tracking
- bookmark-import: move feed when tag changes (delete old, create in new channel)
- feeds: add micropubPostUrl field to createFeed()
- feeds: add getFeedByMicropubPostUrl() for update hook lookup
- feeds: add deleteFeedById() for channel-agnostic removal
- index: pass full tags array (not just first) to importBookmarkAsFollow
- index: capture Location header as postUrl for tracking
- index: handle update action — detect tag change or bookmark-of removal
  and call updateBookmarkFollow() accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 09:33:26 +01:00

339 lines
11 KiB
JavaScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import {
importBookmarkAsFollow,
updateBookmarkFollow,
} 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";
import { handleMediaProxy } from "./lib/media/proxy.js";
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
import {
cleanupAllReadItems,
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";
const defaults = {
mountPath: "/microsub",
};
const router = express.Router();
const readerRouter = express.Router();
const bookmarkHookRouter = express.Router();
bookmarkHookRouter.use((request, response, next) => {
response.on("finish", () => {
if (request.method !== "POST") return;
const action =
request.query?.action || request.body?.action || "create";
const { application } = request.app.locals;
const userId = getUserId(request);
// ── CREATE: new bookmark post ────────────────────────────────────────────
if (
action === "create" &&
(response.statusCode === 201 || response.statusCode === 202)
) {
const bookmarkOf =
request.body?.["bookmark-of"] ||
request.body?.properties?.["bookmark-of"]?.[0];
if (!bookmarkOf) return;
// Collect all tags (all micropub body formats)
const rawCategory =
request.body?.category ||
request.body?.properties?.category;
const tags = Array.isArray(rawCategory)
? rawCategory.filter(Boolean)
: rawCategory
? [rawCategory]
: [];
// The post permalink may appear in the Location response header
const postUrl = response.getHeader?.("Location") || undefined;
importBookmarkAsFollow(application, bookmarkOf, tags, userId, postUrl).catch(
(err) =>
console.warn("[Microsub] bookmark-import failed:", err.message),
);
return;
}
// ── UPDATE: bookmark post edited ─────────────────────────────────────────
if (
action === "update" &&
(response.statusCode === 200 ||
response.statusCode === 204)
) {
const postUrl = request.body?.url;
if (!postUrl) return;
// Detect what changed
const replace = request.body?.replace || {};
const deleteFields = request.body?.delete || [];
const deleteList = Array.isArray(deleteFields)
? deleteFields
: Object.keys(deleteFields);
// bookmark-of removed?
const bookmarkRemoved =
deleteList.includes("bookmark-of") ||
(replace["bookmark-of"] !== undefined &&
!replace["bookmark-of"]?.[0]);
// New tags?
const rawNewTags =
replace.category ||
replace?.properties?.category;
const newTags = rawNewTags
? Array.isArray(rawNewTags)
? rawNewTags.filter(Boolean)
: [rawNewTags]
: null;
if (bookmarkRemoved || newTags) {
updateBookmarkFollow(
application,
postUrl,
{ bookmarkRemoved: !!bookmarkRemoved, newTags: newTags || undefined },
userId,
).catch((err) =>
console.warn("[Microsub] bookmark-update failed:", err.message),
);
}
return;
}
});
next();
});
export default class MicrosubEndpoint {
name = "Microsub endpoint";
/**
* @param {object} options - Plugin options
* @param {string} [options.mountPath] - Path to mount Microsub endpoint
*/
constructor(options = {}) {
this.options = { ...defaults, ...options };
this.mountPath = this.options.mountPath;
}
/**
* Locales directory path
* @returns {string} Path to locales directory
*/
get localesDirectory() {
return path.join(path.dirname(fileURLToPath(import.meta.url)), "locales");
}
/**
* Navigation items for Indiekit admin
* @returns {object} Navigation item configuration
*/
get navigationItems() {
return {
href: path.join(this.options.mountPath, "reader"),
text: "microsub.reader.title",
requiresDatabase: true,
};
}
/**
* Shortcut items for quick actions
* @returns {object} Shortcut item configuration
*/
get shortcutItems() {
return {
url: path.join(this.options.mountPath, "reader", "channels"),
name: "microsub.channels.title",
iconName: "feed",
requiresDatabase: true,
};
}
/**
* 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
*/
get routes() {
// Main Microsub endpoint - dispatches based on action parameter
router.get("/", microsubController.get);
router.post("/", microsubController.post);
// WebSub callback endpoint
router.get("/websub/:id", websubHandler.verify);
router.post("/websub/:id", websubHandler.receive);
// Webmention receiving endpoint
router.post("/webmention", webmentionReceiver.receive);
// Media proxy endpoint
router.get("/media/:hash", handleMediaProxy);
// Reader UI routes (mounted as sub-router for correct baseUrl)
readerRouter.get("/", readerController.index);
readerRouter.get("/channels", readerController.channels);
readerRouter.get("/channels/new", readerController.newChannel);
readerRouter.post("/channels/new", readerController.createChannel);
readerRouter.get("/channels/:uid", readerController.channel);
readerRouter.get("/channels/:uid/settings", readerController.settings);
readerRouter.post(
"/channels/:uid/settings",
readerController.updateSettings,
);
readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
readerRouter.get("/channels/:uid/feeds", readerController.feeds);
readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
readerRouter.post(
"/channels/:uid/feeds/remove",
readerController.removeFeed,
);
readerRouter.get(
"/channels/:uid/feeds/:feedId",
readerController.feedDetails,
);
readerRouter.get(
"/channels/:uid/feeds/:feedId/edit",
readerController.editFeedForm,
);
readerRouter.post(
"/channels/:uid/feeds/:feedId/edit",
readerController.updateFeedUrl,
);
readerRouter.post(
"/channels/:uid/feeds/:feedId/rediscover",
readerController.rediscoverFeed,
);
readerRouter.post(
"/channels/:uid/feeds/:feedId/refresh",
readerController.refreshFeed,
);
readerRouter.get("/item/:id", readerController.item);
readerRouter.get("/compose", readerController.compose);
readerRouter.post("/compose", readerController.submitCompose);
readerRouter.get("/search", readerController.searchPage);
readerRouter.post("/search", readerController.searchFeeds);
readerRouter.post("/subscribe", readerController.subscribe);
readerRouter.get("/actor", readerController.actorProfile);
readerRouter.post("/actor/follow", readerController.followActorAction);
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
readerRouter.post("/api/mark-read", readerController.markAllRead);
readerRouter.get("/opml", opmlController.exportOpml);
readerRouter.get("/timeline", readerController.timeline);
readerRouter.get("/deck", readerController.deck);
readerRouter.get("/deck/settings", readerController.deckSettings);
readerRouter.post("/deck/settings", readerController.saveDeckSettings);
router.use("/reader", readerRouter);
return router;
}
/**
* Public routes (no authentication required)
* @returns {import("express").Router} Express router
*/
get routesPublic() {
const publicRouter = express.Router();
// WebSub verification must be public for hubs to verify
publicRouter.get("/websub/:id", websubHandler.verify);
publicRouter.post("/websub/:id", websubHandler.receive);
// Webmention endpoint must be public
publicRouter.post("/webmention", webmentionReceiver.receive);
// Media proxy must be public for images to load
publicRouter.get("/media/:hash", handleMediaProxy);
return publicRouter;
}
/**
* Initialize plugin
* @param {object} indiekit - Indiekit instance
*/
init(indiekit) {
console.info("[Microsub] Initializing endpoint-microsub plugin");
// Register MongoDB collections
indiekit.addCollection("microsub_channels");
indiekit.addCollection("microsub_feeds");
indiekit.addCollection("microsub_items");
indiekit.addCollection("microsub_notifications");
indiekit.addCollection("microsub_muted");
indiekit.addCollection("microsub_blocked");
indiekit.addCollection("microsub_deck_config");
console.info("[Microsub] Registered MongoDB collections");
// Register endpoint
indiekit.addEndpoint(this);
// Set microsub endpoint URL in config
if (!indiekit.config.application.microsubEndpoint) {
indiekit.config.application.microsubEndpoint = this.mountPath;
}
// Start feed polling scheduler when server starts
// This will be called after the server is ready
if (indiekit.database) {
console.info("[Microsub] Database available, starting scheduler");
startScheduler(indiekit);
// Ensure system channels exist
ensureActivityPubChannel(indiekit).catch((error) => {
console.warn(
"[Microsub] ActivityPub channel creation failed:",
error.message,
);
});
// Create indexes for optimal performance (runs in background)
createIndexes(indiekit).catch((error) => {
console.warn("[Microsub] Index creation failed:", error.message);
});
// Cleanup old read items on startup
cleanupAllReadItems(indiekit).catch((error) => {
console.warn("[Microsub] Startup cleanup failed:", error.message);
});
// Delete stale items (stripped skeletons + unread older than 30 days)
cleanupStaleItems(indiekit).catch((error) => {
console.warn("[Microsub] Stale cleanup failed:", error.message);
});
} else {
console.warn(
"[Microsub] Database not available at init, scheduler not started",
);
}
}
/**
* Cleanup on shutdown
*/
destroy() {
stopScheduler();
}
}