feat: restore full microsub implementation with reader UI

Restores complete implementation from feat/endpoint-microsub branch:
- Reader UI with views (reader.njk, channel.njk, feeds.njk, etc.)
- Feed polling, parsing, and normalization
- WebSub subscriber
- SSE realtime updates
- Redis caching
- Search indexing
- Media proxy
- Webmention processing
This commit is contained in:
Ricardo
2026-02-06 20:20:25 +01:00
parent 66dd5b5c91
commit 4819c229cd
59 changed files with 8418 additions and 82 deletions

113
index.js
View File

@@ -1,12 +1,20 @@
import path from "node:path";
import express from "express";
import { microsubController } from "./lib/controllers/microsub.js";
import { readerController } from "./lib/controllers/reader.js";
import { handleMediaProxy } from "./lib/media/proxy.js";
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
import { createIndexes } from "./lib/storage/items.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();
export default class MicrosubEndpoint {
name = "Microsub endpoint";
@@ -21,7 +29,32 @@ export default class MicrosubEndpoint {
}
/**
* Microsub API routes (authenticated)
* 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,
};
}
/**
* Microsub API and reader UI routes (authenticated)
* @returns {import("express").Router} Express router
*/
get routes() {
@@ -29,9 +62,65 @@ export default class MicrosubEndpoint {
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("/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);
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
@@ -41,7 +130,11 @@ export default class MicrosubEndpoint {
// 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");
console.info("[Microsub] Registered MongoDB collections");
@@ -53,11 +146,27 @@ export default class MicrosubEndpoint {
indiekit.config.application.microsubEndpoint = this.mountPath;
}
// Create indexes for optimal performance (runs in background)
// 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);
// Create indexes for optimal performance (runs in background)
createIndexes(indiekit).catch((error) => {
console.warn("[Microsub] Index creation failed:", error.message);
});
} else {
console.warn(
"[Microsub] Database not available at init, scheduler not started",
);
}
}
/**
* Cleanup on shutdown
*/
destroy() {
stopScheduler();
}
}