Files
indiekit-endpoint-blogroll/index.js
svemagie 624f8db31c feat: auto-create Microsub source to keep blogroll in sync
When the Microsub plugin is detected and no microsub source exists in
blogrollSources, automatically create one on startup so the periodic
sync picks up all Microsub feed subscriptions without manual config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:35:30 +01:00

263 lines
8.6 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;
// Auto-create a Microsub source if the Microsub plugin is installed
// and no microsub source exists yet — keeps blogroll in sync automatically
if (Indiekit.config.application.mongodbUrl) {
const application = Indiekit.config.application;
const ensureMicrosubSource = async () => {
const db = application.getBlogrollDb();
if (!db) return;
if (!application.collections?.has("microsub_channels")) return;
const existing = await db
.collection("blogrollSources")
.findOne({ type: "microsub" });
if (existing) return;
const now = new Date().toISOString();
await db.collection("blogrollSources").insertOne({
type: "microsub",
name: "Microsub (auto)",
url: null,
opmlContent: null,
channelFilter: null,
categoryPrefix: "",
feedlandInstance: null,
feedlandUsername: null,
feedlandCategory: null,
enabled: true,
syncInterval: 60,
lastSyncAt: null,
lastSyncError: null,
createdAt: now,
updatedAt: now,
});
console.log(
"[Blogroll] Auto-created Microsub source to keep blogroll in sync with Microsub subscriptions",
);
};
// Run after a short delay to let collections register
setTimeout(() => {
ensureMicrosubSource().catch((error) => {
console.error("[Blogroll] Failed to auto-create Microsub source:", error.message);
});
}, 10000);
// Start background sync
startSync(Indiekit, this.options);
}
}
destroy() {
stopSync();
}
}