Files
indiekit-endpoint-blogroll/index.js
svemagie 381b0397a5 feat: guard bookmark hook when microsub is available, update category on tag change
- index: skip direct bookmark import when microsub plugin is present;
  microsub handles the flow and notifies blogroll via notifyBlogroll()
  to avoid duplicate entries
- bookmark-import: when blog already exists and category differs, update it
  instead of skipping (handles tag changes on existing bookmark posts)

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

219 lines
7.2 KiB
JavaScript

import express from "express";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { dashboardController } from "./lib/controllers/dashboard.js";
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
maxItemsPerBlog: 50,
maxItemAge: 30, // days
fetchTimeout: 15000,
};
export default class BlogrollEndpoint {
name = "Blogroll endpoint";
constructor(options = {}) {
this.options = { ...defaults, ...options };
this.mountPath = this.options.mountPath;
}
get localesDirectory() {
return path.join(__dirname, "locales");
}
get viewsDirectory() {
return path.join(__dirname, "views");
}
get navigationItems() {
return {
href: this.options.mountPath,
text: "blogroll.title",
requiresDatabase: true,
};
}
get shortcutItems() {
return {
url: this.options.mountPath,
name: "blogroll.title",
iconName: "bookmark",
requiresDatabase: true,
};
}
/**
* 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
*/
get routes() {
// Dashboard
protectedRouter.get("/", dashboardController.get);
// Manual sync trigger
protectedRouter.post("/sync", dashboardController.sync);
// Clear and re-sync
protectedRouter.post("/clear-resync", dashboardController.clearResync);
// Sources management
protectedRouter.get("/sources", sourcesController.list);
protectedRouter.get("/sources/new", sourcesController.newForm);
protectedRouter.post("/sources", sourcesController.create);
protectedRouter.get("/sources/:id", sourcesController.edit);
protectedRouter.post("/sources/:id", sourcesController.update);
protectedRouter.post("/sources/:id/delete", sourcesController.remove);
protectedRouter.post("/sources/:id/sync", sourcesController.sync);
// Blogs management
protectedRouter.get("/blogs", blogsController.list);
protectedRouter.get("/blogs/new", blogsController.newForm);
protectedRouter.post("/blogs", blogsController.create);
protectedRouter.get("/blogs/:id", blogsController.edit);
protectedRouter.post("/blogs/:id", blogsController.update);
protectedRouter.post("/blogs/:id/delete", blogsController.remove);
protectedRouter.post("/blogs/:id/refresh", blogsController.refresh);
// Feed discovery (protected to prevent abuse)
protectedRouter.get("/api/discover", apiController.discover);
// Microsub integration (protected - internal use)
protectedRouter.post("/api/microsub-webhook", apiController.microsubWebhook);
protectedRouter.get("/api/microsub-status", apiController.microsubStatus);
// FeedLand integration (protected - category discovery)
protectedRouter.get("/api/feedland-categories", sourcesController.feedlandCategories);
return protectedRouter;
}
/**
* Public routes (no authentication required)
* Read-only JSON API endpoints for frontend
*/
get routesPublic() {
// Blogs API (read-only)
publicRouter.get("/api/blogs", apiController.listBlogs);
publicRouter.get("/api/blogs/:id", apiController.getBlog);
// Items API (read-only)
publicRouter.get("/api/items", apiController.listItems);
// Categories API
publicRouter.get("/api/categories", apiController.listCategories);
// Status API
publicRouter.get("/api/status", apiController.status);
// OPML export
publicRouter.get("/api/opml", apiController.exportOpml);
publicRouter.get("/api/opml/:category", apiController.exportOpmlCategory);
return publicRouter;
}
init(Indiekit) {
Indiekit.addEndpoint(this);
// Add MongoDB collections
Indiekit.addCollection("blogrollSources");
Indiekit.addCollection("blogrollBlogs");
Indiekit.addCollection("blogrollItems");
Indiekit.addCollection("blogrollMeta");
// Store config in application for controller access
Indiekit.config.application.blogrollConfig = this.options;
Indiekit.config.application.blogrollEndpoint = this.mountPath;
// Store database getter for controller access
Indiekit.config.application.getBlogrollDb = () => Indiekit.database;
// Start background sync if database is available
if (Indiekit.config.application.mongodbUrl) {
startSync(Indiekit, this.options);
}
}
destroy() {
stopSync();
}
}