@rmdes/indiekit-endpoint-microsub 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +111 -0
  2. package/index.js +140 -0
  3. package/lib/cache/redis.js +133 -0
  4. package/lib/controllers/block.js +85 -0
  5. package/lib/controllers/channels.js +135 -0
  6. package/lib/controllers/events.js +56 -0
  7. package/lib/controllers/follow.js +108 -0
  8. package/lib/controllers/microsub.js +138 -0
  9. package/lib/controllers/mute.js +124 -0
  10. package/lib/controllers/preview.js +67 -0
  11. package/lib/controllers/reader.js +218 -0
  12. package/lib/controllers/search.js +142 -0
  13. package/lib/controllers/timeline.js +117 -0
  14. package/lib/feeds/atom.js +61 -0
  15. package/lib/feeds/fetcher.js +205 -0
  16. package/lib/feeds/hfeed.js +177 -0
  17. package/lib/feeds/jsonfeed.js +43 -0
  18. package/lib/feeds/normalizer.js +586 -0
  19. package/lib/feeds/parser.js +124 -0
  20. package/lib/feeds/rss.js +61 -0
  21. package/lib/polling/processor.js +201 -0
  22. package/lib/polling/scheduler.js +128 -0
  23. package/lib/polling/tier.js +139 -0
  24. package/lib/realtime/broker.js +241 -0
  25. package/lib/search/indexer.js +90 -0
  26. package/lib/search/query.js +197 -0
  27. package/lib/storage/channels.js +281 -0
  28. package/lib/storage/feeds.js +286 -0
  29. package/lib/storage/filters.js +265 -0
  30. package/lib/storage/items.js +419 -0
  31. package/lib/storage/read-state.js +109 -0
  32. package/lib/utils/jf2.js +170 -0
  33. package/lib/utils/pagination.js +157 -0
  34. package/lib/utils/validation.js +217 -0
  35. package/lib/webmention/processor.js +214 -0
  36. package/lib/webmention/receiver.js +54 -0
  37. package/lib/webmention/verifier.js +308 -0
  38. package/lib/websub/discovery.js +129 -0
  39. package/lib/websub/handler.js +163 -0
  40. package/lib/websub/subscriber.js +181 -0
  41. package/locales/en.json +80 -0
  42. package/package.json +54 -0
  43. package/views/channel-new.njk +33 -0
  44. package/views/channel.njk +41 -0
  45. package/views/compose.njk +61 -0
  46. package/views/item.njk +85 -0
  47. package/views/partials/actions.njk +15 -0
  48. package/views/partials/author.njk +17 -0
  49. package/views/partials/item-card.njk +65 -0
  50. package/views/partials/timeline.njk +10 -0
  51. package/views/reader.njk +37 -0
  52. package/views/settings.njk +81 -0
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @indiekit/endpoint-microsub
2
+
3
+ Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the [Microsub protocol](https://indieweb.org/Microsub).
4
+
5
+ ## Features
6
+
7
+ - **Full Microsub API** - Channels, timeline, follow/unfollow, search, preview, mute, block
8
+ - **Multiple feed formats** - RSS 1.0/2.0, Atom, JSON Feed, h-feed (Microformats)
9
+ - **External client support** - Works with Monocle, Together, IndiePass
10
+ - **Built-in reader UI** - Social reading experience in Indiekit admin
11
+ - **Real-time updates** - Server-Sent Events (SSE) and WebSub integration
12
+ - **Adaptive polling** - Tier-based feed fetching inspired by Ekster
13
+ - **Direct webmention receiving** - Notifications channel for mentions
14
+
15
+ ## Installation
16
+
17
+ `npm install @indiekit/endpoint-microsub`
18
+
19
+ ## Usage
20
+
21
+ Add `@indiekit/endpoint-microsub` to the list of plugins in your configuration:
22
+
23
+ ```js
24
+ export default {
25
+ plugins: ["@indiekit/endpoint-microsub"],
26
+ };
27
+ ```
28
+
29
+ ## Options
30
+
31
+ | Option | Type | Description |
32
+ | :----------- | :------- | :--------------------------------------------------------------- |
33
+ | `mountPath` | `string` | Path to mount Microsub API. _Optional_, defaults to `/microsub`. |
34
+ | `readerPath` | `string` | Path to mount reader UI. _Optional_, defaults to `/reader`. |
35
+
36
+ ## Endpoints
37
+
38
+ ### Microsub API
39
+
40
+ The main Microsub endpoint is mounted at `/microsub` (configurable).
41
+
42
+ **Discovery**: Add this to your site's `<head>`:
43
+
44
+ ```html
45
+ <link rel="microsub" href="https://yoursite.com/microsub" />
46
+ ```
47
+
48
+ ### Supported actions
49
+
50
+ | Action | GET | POST | Description |
51
+ | :--------- | :-- | :--- | :--------------------------------------------- |
52
+ | `channels` | ✓ | ✓ | List, create, update, delete, reorder channels |
53
+ | `timeline` | ✓ | ✓ | Get timeline, mark read/unread, remove items |
54
+ | `follow` | ✓ | ✓ | List followed feeds, subscribe to new feeds |
55
+ | `unfollow` | - | ✓ | Unsubscribe from feeds |
56
+ | `search` | ✓ | ✓ | Feed discovery and full-text search |
57
+ | `preview` | ✓ | ✓ | Preview feed before subscribing |
58
+ | `mute` | ✓ | ✓ | List muted URLs, mute/unmute |
59
+ | `block` | ✓ | ✓ | List blocked URLs, block/unblock |
60
+ | `events` | ✓ | - | Server-Sent Events stream |
61
+
62
+ ### Reader UI
63
+
64
+ The built-in reader is mounted at `/reader` (configurable) and provides:
65
+
66
+ - Channel list with unread counts
67
+ - Timeline view with items
68
+ - Mark as read on scroll/click
69
+ - Like/reply/repost via Micropub
70
+ - Channel settings (filters)
71
+ - Compose modal
72
+
73
+ ### WebSub callbacks
74
+
75
+ WebSub hub callbacks are handled at `/microsub/websub/:id`.
76
+
77
+ ### Webmention receiving
78
+
79
+ Direct webmentions can be sent to `/microsub/webmention`.
80
+
81
+ ## MongoDB Collections
82
+
83
+ This plugin creates the following collections:
84
+
85
+ - `microsub_channels` - User's feed channels
86
+ - `microsub_feeds` - Subscribed feeds
87
+ - `microsub_items` - Timeline entries
88
+ - `microsub_notifications` - Webmention notifications
89
+ - `microsub_muted` - Muted URLs
90
+ - `microsub_blocked` - Blocked URLs
91
+
92
+ ## Dependencies
93
+
94
+ - **feedparser** - RSS/Atom parsing
95
+ - **microformats-parser** - h-feed parsing
96
+ - **ioredis** - Redis client (optional, for caching/pub-sub)
97
+ - **sanitize-html** - XSS prevention
98
+
99
+ ## External Clients
100
+
101
+ This endpoint is compatible with:
102
+
103
+ - [Monocle](https://monocle.p3k.io/) - Web-based reader
104
+ - [Together](https://together.tpxl.io/) - Web-based reader
105
+ - [IndiePass](https://indiepass.app/) - Mobile/desktop app (archived)
106
+
107
+ ## References
108
+
109
+ - [Microsub Specification](https://indieweb.org/Microsub-spec)
110
+ - [Ekster](https://github.com/pstuifzand/ekster) - Reference implementation in Go
111
+ - [Aperture](https://github.com/aaronpk/Aperture) - Popular Microsub server in PHP
package/index.js ADDED
@@ -0,0 +1,140 @@
1
+ import path from "node:path";
2
+
3
+ import express from "express";
4
+
5
+ import { microsubController } from "./lib/controllers/microsub.js";
6
+ import { readerController } from "./lib/controllers/reader.js";
7
+ import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
8
+ import { webmentionReceiver } from "./lib/webmention/receiver.js";
9
+ import { websubHandler } from "./lib/websub/handler.js";
10
+
11
+ const defaults = {
12
+ mountPath: "/microsub",
13
+ };
14
+ const router = express.Router();
15
+ const readerRouter = express.Router();
16
+
17
+ export default class MicrosubEndpoint {
18
+ name = "Microsub endpoint";
19
+
20
+ /**
21
+ * @param {object} options - Plugin options
22
+ * @param {string} [options.mountPath] - Path to mount Microsub endpoint
23
+ */
24
+ constructor(options = {}) {
25
+ this.options = { ...defaults, ...options };
26
+ this.mountPath = this.options.mountPath;
27
+ }
28
+
29
+ /**
30
+ * Navigation items for Indiekit admin
31
+ * @returns {object} Navigation item configuration
32
+ */
33
+ get navigationItems() {
34
+ return {
35
+ href: path.join(this.options.mountPath, "reader"),
36
+ text: "microsub.reader.title",
37
+ requiresDatabase: true,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Shortcut items for quick actions
43
+ * @returns {object} Shortcut item configuration
44
+ */
45
+ get shortcutItems() {
46
+ return {
47
+ url: path.join(this.options.mountPath, "reader", "channels"),
48
+ name: "microsub.channels.title",
49
+ iconName: "feed",
50
+ requiresDatabase: true,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Microsub API and reader UI routes (authenticated)
56
+ * @returns {import("express").Router} Express router
57
+ */
58
+ get routes() {
59
+ // Main Microsub endpoint - dispatches based on action parameter
60
+ router.get("/", microsubController.get);
61
+ router.post("/", microsubController.post);
62
+
63
+ // WebSub callback endpoint
64
+ router.get("/websub/:id", websubHandler.verify);
65
+ router.post("/websub/:id", websubHandler.receive);
66
+
67
+ // Webmention receiving endpoint
68
+ router.post("/webmention", webmentionReceiver.receive);
69
+
70
+ // Reader UI routes (mounted as sub-router for correct baseUrl)
71
+ readerRouter.get("/", readerController.index);
72
+ readerRouter.get("/channels", readerController.channels);
73
+ readerRouter.get("/channels/new", readerController.newChannel);
74
+ readerRouter.post("/channels/new", readerController.createChannel);
75
+ readerRouter.get("/channels/:uid", readerController.channel);
76
+ readerRouter.get("/channels/:uid/settings", readerController.settings);
77
+ readerRouter.post(
78
+ "/channels/:uid/settings",
79
+ readerController.updateSettings,
80
+ );
81
+ readerRouter.get("/item/:id", readerController.item);
82
+ readerRouter.get("/compose", readerController.compose);
83
+ readerRouter.post("/compose", readerController.submitCompose);
84
+ router.use("/reader", readerRouter);
85
+
86
+ return router;
87
+ }
88
+
89
+ /**
90
+ * Public routes (no authentication required)
91
+ * @returns {import("express").Router} Express router
92
+ */
93
+ get routesPublic() {
94
+ const publicRouter = express.Router();
95
+
96
+ // WebSub verification must be public for hubs to verify
97
+ publicRouter.get("/websub/:id", websubHandler.verify);
98
+ publicRouter.post("/websub/:id", websubHandler.receive);
99
+
100
+ // Webmention endpoint must be public
101
+ publicRouter.post("/webmention", webmentionReceiver.receive);
102
+
103
+ return publicRouter;
104
+ }
105
+
106
+ /**
107
+ * Initialize plugin
108
+ * @param {object} indiekit - Indiekit instance
109
+ */
110
+ init(indiekit) {
111
+ // Register MongoDB collections
112
+ indiekit.addCollection("microsub_channels");
113
+ indiekit.addCollection("microsub_feeds");
114
+ indiekit.addCollection("microsub_items");
115
+ indiekit.addCollection("microsub_notifications");
116
+ indiekit.addCollection("microsub_muted");
117
+ indiekit.addCollection("microsub_blocked");
118
+
119
+ // Register endpoint
120
+ indiekit.addEndpoint(this);
121
+
122
+ // Set microsub endpoint URL in config
123
+ if (!indiekit.config.application.microsubEndpoint) {
124
+ indiekit.config.application.microsubEndpoint = this.mountPath;
125
+ }
126
+
127
+ // Start feed polling scheduler when server starts
128
+ // This will be called after the server is ready
129
+ if (indiekit.database) {
130
+ startScheduler(indiekit);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Cleanup on shutdown
136
+ */
137
+ destroy() {
138
+ stopScheduler();
139
+ }
140
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Redis caching utilities
3
+ * @module cache/redis
4
+ */
5
+
6
+ /**
7
+ * Get Redis client from application
8
+ * @param {object} application - Indiekit application
9
+ * @returns {object|undefined} Redis client or undefined
10
+ */
11
+ export function getRedisClient(application) {
12
+ // Check if Redis is configured
13
+ if (application.redis) {
14
+ return application.redis;
15
+ }
16
+
17
+ // Check for Redis URL in config
18
+ if (application.config?.application?.redisUrl) {
19
+ // Lazily create Redis connection
20
+ // This will be implemented when Redis support is added to Indiekit core
21
+ return;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Get value from cache
27
+ * @param {object} redis - Redis client
28
+ * @param {string} key - Cache key
29
+ * @returns {Promise<object|undefined>} Cached value or undefined
30
+ */
31
+ export async function getCache(redis, key) {
32
+ if (!redis) {
33
+ return;
34
+ }
35
+
36
+ try {
37
+ const value = await redis.get(key);
38
+ if (value) {
39
+ return JSON.parse(value);
40
+ }
41
+ } catch {
42
+ // Ignore cache errors
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Set value in cache
48
+ * @param {object} redis - Redis client
49
+ * @param {string} key - Cache key
50
+ * @param {object} value - Value to cache
51
+ * @param {number} [ttl] - Time to live in seconds
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function setCache(redis, key, value, ttl = 300) {
55
+ if (!redis) {
56
+ return;
57
+ }
58
+
59
+ try {
60
+ const serialized = JSON.stringify(value);
61
+ await (ttl
62
+ ? redis.set(key, serialized, "EX", ttl)
63
+ : redis.set(key, serialized));
64
+ } catch {
65
+ // Ignore cache errors
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Delete value from cache
71
+ * @param {object} redis - Redis client
72
+ * @param {string} key - Cache key
73
+ * @returns {Promise<void>}
74
+ */
75
+ export async function deleteCache(redis, key) {
76
+ if (!redis) {
77
+ return;
78
+ }
79
+
80
+ try {
81
+ await redis.del(key);
82
+ } catch {
83
+ // Ignore cache errors
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Publish event to channel
89
+ * @param {object} redis - Redis client
90
+ * @param {string} channel - Channel name
91
+ * @param {object} data - Event data
92
+ * @returns {Promise<void>}
93
+ */
94
+ export async function publishEvent(redis, channel, data) {
95
+ if (!redis) {
96
+ return;
97
+ }
98
+
99
+ try {
100
+ await redis.publish(channel, JSON.stringify(data));
101
+ } catch {
102
+ // Ignore pub/sub errors
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Subscribe to channel
108
+ * @param {object} redis - Redis client (must be separate connection for pub/sub)
109
+ * @param {string} channel - Channel name
110
+ * @param {(data: object) => void} callback - Callback function for messages
111
+ * @returns {Promise<void>}
112
+ */
113
+ export async function subscribeToChannel(redis, channel, callback) {
114
+ if (!redis) {
115
+ return;
116
+ }
117
+
118
+ try {
119
+ await redis.subscribe(channel);
120
+ redis.on("message", (receivedChannel, message) => {
121
+ if (receivedChannel === channel) {
122
+ try {
123
+ const data = JSON.parse(message);
124
+ callback(data);
125
+ } catch {
126
+ callback(message);
127
+ }
128
+ }
129
+ });
130
+ } catch {
131
+ // Ignore subscription errors
132
+ }
133
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Block controller
3
+ * @module controllers/block
4
+ */
5
+
6
+ import { deleteItemsByAuthorUrl } from "../storage/items.js";
7
+ import { validateUrl } from "../utils/validation.js";
8
+
9
+ /**
10
+ * Get blocked collection
11
+ * @param {object} application - Indiekit application
12
+ * @returns {object} MongoDB collection
13
+ */
14
+ function getCollection(application) {
15
+ return application.collections.get("microsub_blocked");
16
+ }
17
+
18
+ /**
19
+ * List blocked URLs
20
+ * GET ?action=block
21
+ * @param {object} request - Express request
22
+ * @param {object} response - Express response
23
+ */
24
+ export async function list(request, response) {
25
+ const { application } = request.app.locals;
26
+ const userId = request.session?.userId;
27
+
28
+ const collection = getCollection(application);
29
+ const blocked = await collection.find({ userId }).toArray();
30
+ const items = blocked.map((b) => ({ url: b.url }));
31
+
32
+ response.json({ items });
33
+ }
34
+
35
+ /**
36
+ * Block a URL
37
+ * POST ?action=block
38
+ * @param {object} request - Express request
39
+ * @param {object} response - Express response
40
+ */
41
+ export async function block(request, response) {
42
+ const { application } = request.app.locals;
43
+ const userId = request.session?.userId;
44
+ const { url } = request.body;
45
+
46
+ validateUrl(url);
47
+
48
+ const collection = getCollection(application);
49
+
50
+ // Check if already blocked
51
+ const existing = await collection.findOne({ userId, url });
52
+ if (!existing) {
53
+ await collection.insertOne({
54
+ userId,
55
+ url,
56
+ createdAt: new Date(),
57
+ });
58
+ }
59
+
60
+ // Remove past items from blocked URL
61
+ await deleteItemsByAuthorUrl(application, userId, url);
62
+
63
+ response.json({ result: "ok" });
64
+ }
65
+
66
+ /**
67
+ * Unblock a URL
68
+ * POST ?action=unblock
69
+ * @param {object} request - Express request
70
+ * @param {object} response - Express response
71
+ */
72
+ export async function unblock(request, response) {
73
+ const { application } = request.app.locals;
74
+ const userId = request.session?.userId;
75
+ const { url } = request.body;
76
+
77
+ validateUrl(url);
78
+
79
+ const collection = getCollection(application);
80
+ await collection.deleteOne({ userId, url });
81
+
82
+ response.json({ result: "ok" });
83
+ }
84
+
85
+ export const blockController = { list, block, unblock };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Channel management controller
3
+ * @module controllers/channels
4
+ */
5
+
6
+ import { IndiekitError } from "@indiekit/error";
7
+
8
+ import {
9
+ getChannels,
10
+ getChannel,
11
+ createChannel,
12
+ updateChannel,
13
+ deleteChannel,
14
+ reorderChannels,
15
+ } from "../storage/channels.js";
16
+ import {
17
+ validateChannel,
18
+ validateChannelName,
19
+ parseArrayParameter as parseArrayParametereter,
20
+ } from "../utils/validation.js";
21
+
22
+ /**
23
+ * List all channels
24
+ * GET ?action=channels
25
+ * @param {object} request - Express request
26
+ * @param {object} response - Express response
27
+ */
28
+ export async function list(request, response) {
29
+ const { application } = request.app.locals;
30
+ const userId = request.session?.userId;
31
+
32
+ const channels = await getChannels(application, userId);
33
+
34
+ response.json({ channels });
35
+ }
36
+
37
+ /**
38
+ * Handle channel actions (create, update, delete, order)
39
+ * POST ?action=channels
40
+ * @param {object} request - Express request
41
+ * @param {object} response - Express response
42
+ */
43
+ export async function action(request, response) {
44
+ const { application } = request.app.locals;
45
+ const userId = request.session?.userId;
46
+ const { method, name, uid } = request.body;
47
+
48
+ // Delete channel
49
+ if (method === "delete") {
50
+ validateChannel(uid);
51
+
52
+ const deleted = await deleteChannel(application, uid, userId);
53
+ if (!deleted) {
54
+ throw new IndiekitError("Channel not found or cannot be deleted", {
55
+ status: 404,
56
+ });
57
+ }
58
+
59
+ return response.json({ deleted: uid });
60
+ }
61
+
62
+ // Reorder channels
63
+ if (method === "order") {
64
+ const channelUids = parseArrayParametereter(request.body, "channels");
65
+ if (channelUids.length === 0) {
66
+ throw new IndiekitError("Missing channels[] parameter", {
67
+ status: 400,
68
+ });
69
+ }
70
+
71
+ await reorderChannels(application, channelUids, userId);
72
+
73
+ const channels = await getChannels(application, userId);
74
+ return response.json({ channels });
75
+ }
76
+
77
+ // Update existing channel
78
+ if (uid) {
79
+ validateChannel(uid);
80
+
81
+ if (name) {
82
+ validateChannelName(name);
83
+ }
84
+
85
+ const channel = await updateChannel(application, uid, { name }, userId);
86
+ if (!channel) {
87
+ throw new IndiekitError("Channel not found", {
88
+ status: 404,
89
+ });
90
+ }
91
+
92
+ return response.json({
93
+ uid: channel.uid,
94
+ name: channel.name,
95
+ });
96
+ }
97
+
98
+ // Create new channel
99
+ validateChannelName(name);
100
+
101
+ const channel = await createChannel(application, { name, userId });
102
+
103
+ response.status(201).json({
104
+ uid: channel.uid,
105
+ name: channel.name,
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Get a single channel by UID
111
+ * @param {object} request - Express request
112
+ * @param {object} response - Express response
113
+ */
114
+ export async function get(request, response) {
115
+ const { application } = request.app.locals;
116
+ const userId = request.session?.userId;
117
+ const { uid } = request.params;
118
+
119
+ validateChannel(uid);
120
+
121
+ const channel = await getChannel(application, uid, userId);
122
+ if (!channel) {
123
+ throw new IndiekitError("Channel not found", {
124
+ status: 404,
125
+ });
126
+ }
127
+
128
+ response.json({
129
+ uid: channel.uid,
130
+ name: channel.name,
131
+ settings: channel.settings,
132
+ });
133
+ }
134
+
135
+ export const channelsController = { list, action, get };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Server-Sent Events controller
3
+ * @module controllers/events
4
+ */
5
+
6
+ import {
7
+ addClient,
8
+ removeClient,
9
+ sendEvent,
10
+ subscribeClient,
11
+ } from "../realtime/broker.js";
12
+
13
+ /**
14
+ * SSE stream endpoint
15
+ * GET ?action=events
16
+ * @param {object} request - Express request
17
+ * @param {object} response - Express response
18
+ */
19
+ export async function stream(request, response) {
20
+ const { application } = request.app.locals;
21
+ const userId = request.session?.userId;
22
+
23
+ // Set SSE headers
24
+ response.setHeader("Content-Type", "text/event-stream");
25
+ response.setHeader("Cache-Control", "no-cache");
26
+ response.setHeader("Connection", "keep-alive");
27
+ response.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
28
+
29
+ // Flush headers immediately
30
+ response.flushHeaders();
31
+
32
+ // Add client to broker (handles ping internally)
33
+ const client = addClient(response, userId, application);
34
+
35
+ // Subscribe to channels from query parameter
36
+ const { channels } = request.query;
37
+ if (channels) {
38
+ const channelList = Array.isArray(channels) ? channels : [channels];
39
+ for (const channelId of channelList) {
40
+ subscribeClient(response, channelId);
41
+ }
42
+ }
43
+
44
+ // Send initial event
45
+ sendEvent(response, "started", {
46
+ version: "1.0.0",
47
+ channels: [...client.channels],
48
+ });
49
+
50
+ // Handle client disconnect
51
+ request.on("close", () => {
52
+ removeClient(response);
53
+ });
54
+ }
55
+
56
+ export const eventsController = { stream };