@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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * WebSub callback handler
3
+ * @module websub/handler
4
+ */
5
+
6
+ import { parseFeed } from "../feeds/parser.js";
7
+ import { processFeed } from "../polling/processor.js";
8
+ import { getFeedBySubscriptionId, updateFeedWebsub } from "../storage/feeds.js";
9
+
10
+ import { verifySignature } from "./subscriber.js";
11
+
12
+ /**
13
+ * Verify WebSub subscription
14
+ * GET /microsub/websub/:id
15
+ * @param {object} request - Express request
16
+ * @param {object} response - Express response
17
+ */
18
+ export async function verify(request, response) {
19
+ const { id } = request.params;
20
+ const {
21
+ "hub.topic": topic,
22
+ "hub.challenge": challenge,
23
+ "hub.lease_seconds": leaseSeconds,
24
+ } = request.query;
25
+
26
+ if (!challenge) {
27
+ return response.status(400).send("Missing hub.challenge");
28
+ }
29
+
30
+ const { application } = request.app.locals;
31
+ const feed = await getFeedBySubscriptionId(application, id);
32
+
33
+ if (!feed) {
34
+ return response.status(404).send("Subscription not found");
35
+ }
36
+
37
+ // Verify topic matches (allow both feed URL and topic URL)
38
+ const expectedTopic = feed.websub?.topic || feed.url;
39
+ if (topic !== feed.url && topic !== expectedTopic) {
40
+ return response.status(400).send("Topic mismatch");
41
+ }
42
+
43
+ // Update lease seconds if provided
44
+ if (leaseSeconds) {
45
+ const seconds = Number.parseInt(leaseSeconds, 10);
46
+ if (seconds > 0) {
47
+ await updateFeedWebsub(application, id, {
48
+ hub: feed.websub?.hub,
49
+ topic: expectedTopic,
50
+ leaseSeconds: seconds,
51
+ secret: feed.websub?.secret,
52
+ });
53
+ }
54
+ }
55
+
56
+ // Mark subscription as active (not pending)
57
+ if (feed.websub?.pending) {
58
+ await updateFeedWebsub(application, id, {
59
+ hub: feed.websub?.hub,
60
+ topic: expectedTopic,
61
+ secret: feed.websub?.secret,
62
+ leaseSeconds: feed.websub?.leaseSeconds,
63
+ pending: false,
64
+ });
65
+ }
66
+
67
+ console.log(`[Microsub] WebSub subscription verified for ${feed.url}`);
68
+
69
+ // Return challenge to verify subscription
70
+ response.type("text/plain").send(challenge);
71
+ }
72
+
73
+ /**
74
+ * Receive WebSub notification
75
+ * POST /microsub/websub/:id
76
+ * @param {object} request - Express request
77
+ * @param {object} response - Express response
78
+ */
79
+ export async function receive(request, response) {
80
+ const { id } = request.params;
81
+ const { application } = request.app.locals;
82
+
83
+ const feed = await getFeedBySubscriptionId(application, id);
84
+ if (!feed) {
85
+ return response.status(404).send("Subscription not found");
86
+ }
87
+
88
+ // Verify X-Hub-Signature if we have a secret
89
+ if (feed.websub?.secret) {
90
+ const signature =
91
+ request.headers["x-hub-signature-256"] ||
92
+ request.headers["x-hub-signature"];
93
+
94
+ if (!signature) {
95
+ return response.status(401).send("Missing signature");
96
+ }
97
+
98
+ // Get raw body for signature verification
99
+ const rawBody =
100
+ typeof request.body === "string"
101
+ ? request.body
102
+ : JSON.stringify(request.body);
103
+
104
+ if (!verifySignature(signature, rawBody, feed.websub.secret)) {
105
+ console.warn(`[Microsub] Invalid WebSub signature for ${feed.url}`);
106
+ return response.status(401).send("Invalid signature");
107
+ }
108
+ }
109
+
110
+ // Acknowledge receipt immediately
111
+ response.status(200).send("OK");
112
+
113
+ // Process pushed content in background
114
+ setImmediate(async () => {
115
+ try {
116
+ await processWebsubContent(
117
+ application,
118
+ feed,
119
+ request.headers["content-type"],
120
+ request.body,
121
+ );
122
+ } catch (error) {
123
+ console.error(
124
+ `[Microsub] Error processing WebSub content for ${feed.url}: ${error.message}`,
125
+ );
126
+ }
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Process WebSub pushed content
132
+ * @param {object} application - Indiekit application
133
+ * @param {object} feed - Feed document
134
+ * @param {string} contentType - Content-Type header
135
+ * @param {string|object} body - Request body
136
+ * @returns {Promise<void>}
137
+ */
138
+ async function processWebsubContent(application, feed, contentType, body) {
139
+ // Convert body to string if needed
140
+ const content = typeof body === "string" ? body : JSON.stringify(body);
141
+
142
+ try {
143
+ // Parse the pushed content
144
+ const parsed = await parseFeed(content, feed.url, { contentType });
145
+
146
+ console.log(
147
+ `[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
148
+ );
149
+
150
+ // Process like a normal feed fetch but with pre-parsed content
151
+ // This reuses the existing feed processing logic
152
+ await processFeed(application, {
153
+ ...feed,
154
+ _websubContent: parsed,
155
+ });
156
+ } catch (error) {
157
+ console.error(
158
+ `[Microsub] Failed to parse WebSub content for ${feed.url}: ${error.message}`,
159
+ );
160
+ }
161
+ }
162
+
163
+ export const websubHandler = { verify, receive };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * WebSub subscriber
3
+ * @module websub/subscriber
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+
8
+ import { updateFeedWebsub } from "../storage/feeds.js";
9
+
10
+ const DEFAULT_LEASE_SECONDS = 86_400 * 7; // 7 days
11
+
12
+ /**
13
+ * Subscribe to a WebSub hub
14
+ * @param {object} application - Indiekit application
15
+ * @param {object} feed - Feed document with websub.hub
16
+ * @param {string} callbackUrl - Callback URL for this subscription
17
+ * @returns {Promise<boolean>} Whether subscription was initiated
18
+ */
19
+ export async function subscribe(application, feed, callbackUrl) {
20
+ if (!feed.websub?.hub) {
21
+ return false;
22
+ }
23
+
24
+ const topic = feed.websub.topic || feed.url;
25
+ const secret = generateSecret();
26
+
27
+ try {
28
+ const response = await fetch(feed.websub.hub, {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/x-www-form-urlencoded",
32
+ },
33
+ body: new URLSearchParams({
34
+ "hub.mode": "subscribe",
35
+ "hub.topic": topic,
36
+ "hub.callback": callbackUrl,
37
+ "hub.secret": secret,
38
+ "hub.lease_seconds": String(DEFAULT_LEASE_SECONDS),
39
+ }),
40
+ });
41
+
42
+ // 202 Accepted means subscription is pending verification
43
+ // 204 No Content means subscription was immediately accepted
44
+ if (response.status === 202 || response.status === 204) {
45
+ // Store the secret for signature verification
46
+ await updateFeedWebsub(application, feed._id, {
47
+ hub: feed.websub.hub,
48
+ topic,
49
+ secret,
50
+ pending: true,
51
+ });
52
+ return true;
53
+ }
54
+
55
+ console.error(
56
+ `[Microsub] WebSub subscription failed: ${response.status} ${response.statusText}`,
57
+ );
58
+ return false;
59
+ } catch (error) {
60
+ console.error(`[Microsub] WebSub subscription error: ${error.message}`);
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Unsubscribe from a WebSub hub
67
+ * @param {object} application - Indiekit application
68
+ * @param {object} feed - Feed document with websub.hub
69
+ * @param {string} callbackUrl - Callback URL for this subscription
70
+ * @returns {Promise<boolean>} Whether unsubscription was initiated
71
+ */
72
+ export async function unsubscribe(application, feed, callbackUrl) {
73
+ if (!feed.websub?.hub) {
74
+ return false;
75
+ }
76
+
77
+ const topic = feed.websub.topic || feed.url;
78
+
79
+ try {
80
+ const response = await fetch(feed.websub.hub, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/x-www-form-urlencoded",
84
+ },
85
+ body: new URLSearchParams({
86
+ "hub.mode": "unsubscribe",
87
+ "hub.topic": topic,
88
+ "hub.callback": callbackUrl,
89
+ }),
90
+ });
91
+
92
+ if (response.status === 202 || response.status === 204) {
93
+ // Clear WebSub data from feed
94
+ await updateFeedWebsub(application, feed._id, {
95
+ hub: feed.websub.hub,
96
+ topic,
97
+ secret: undefined,
98
+ leaseSeconds: undefined,
99
+ pending: false,
100
+ });
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ } catch (error) {
106
+ console.error(`[Microsub] WebSub unsubscribe error: ${error.message}`);
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Generate a random secret for signature verification
113
+ * @returns {string} Random hex string
114
+ */
115
+ function generateSecret() {
116
+ return crypto.randomBytes(32).toString("hex");
117
+ }
118
+
119
+ /**
120
+ * Verify WebSub signature
121
+ * @param {string} signature - X-Hub-Signature header value
122
+ * @param {Buffer|string} body - Request body
123
+ * @param {string} secret - Subscription secret
124
+ * @returns {boolean} Whether signature is valid
125
+ */
126
+ export function verifySignature(signature, body, secret) {
127
+ if (!signature || !secret) {
128
+ return false;
129
+ }
130
+
131
+ // Signature format: sha1=<hex> or sha256=<hex>
132
+ const [algorithm, hash] = signature.split("=");
133
+ if (!algorithm || !hash) {
134
+ return false;
135
+ }
136
+
137
+ // Normalize algorithm name
138
+ const algo = algorithm.toLowerCase().replace("sha", "sha");
139
+
140
+ try {
141
+ const expectedHash = crypto
142
+ .createHmac(algo, secret)
143
+ .update(body)
144
+ .digest("hex");
145
+
146
+ // Use timing-safe comparison
147
+ return crypto.timingSafeEqual(
148
+ Buffer.from(hash, "hex"),
149
+ Buffer.from(expectedHash, "hex"),
150
+ );
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Check if a WebSub subscription is about to expire
158
+ * @param {object} feed - Feed document
159
+ * @param {number} [thresholdSeconds] - Seconds before expiry to consider "expiring"
160
+ * @returns {boolean} Whether subscription is expiring soon
161
+ */
162
+ export function isSubscriptionExpiring(feed, thresholdSeconds = 86_400) {
163
+ if (!feed.websub?.expiresAt) {
164
+ return false;
165
+ }
166
+
167
+ const expiresAt = new Date(feed.websub.expiresAt);
168
+ const threshold = new Date(Date.now() + thresholdSeconds * 1000);
169
+
170
+ return expiresAt <= threshold;
171
+ }
172
+
173
+ /**
174
+ * Get callback URL for a feed
175
+ * @param {string} baseUrl - Base URL of the Microsub endpoint
176
+ * @param {string} feedId - Feed ID
177
+ * @returns {string} Callback URL
178
+ */
179
+ export function getCallbackUrl(baseUrl, feedId) {
180
+ return `${baseUrl}/microsub/websub/${feedId}`;
181
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "microsub": {
3
+ "reader": {
4
+ "title": "Reader",
5
+ "empty": "No items to display",
6
+ "markAllRead": "Mark all as read",
7
+ "newer": "Newer",
8
+ "older": "Older"
9
+ },
10
+ "channels": {
11
+ "title": "Channels",
12
+ "new": "New channel",
13
+ "create": "Create channel",
14
+ "delete": "Delete channel",
15
+ "settings": "Channel settings",
16
+ "empty": "No channels yet. Create one to get started.",
17
+ "notifications": "Notifications"
18
+ },
19
+ "timeline": {
20
+ "title": "Timeline",
21
+ "empty": "No items in this channel",
22
+ "markRead": "Mark as read",
23
+ "markUnread": "Mark as unread",
24
+ "remove": "Remove"
25
+ },
26
+ "feeds": {
27
+ "title": "Feeds",
28
+ "follow": "Follow",
29
+ "unfollow": "Unfollow",
30
+ "empty": "No feeds followed in this channel"
31
+ },
32
+ "item": {
33
+ "reply": "Reply",
34
+ "like": "Like",
35
+ "repost": "Repost",
36
+ "bookmark": "Bookmark",
37
+ "viewOriginal": "View original"
38
+ },
39
+ "compose": {
40
+ "title": "Compose",
41
+ "content": "What's on your mind?",
42
+ "submit": "Post",
43
+ "cancel": "Cancel",
44
+ "replyTo": "Replying to",
45
+ "likeOf": "Liking",
46
+ "repostOf": "Reposting"
47
+ },
48
+ "settings": {
49
+ "title": "%{channel} settings",
50
+ "excludeTypes": "Exclude interaction types",
51
+ "excludeTypesHelp": "Select types of posts to hide from this channel",
52
+ "excludeRegex": "Exclude pattern",
53
+ "excludeRegexHelp": "Regular expression to filter out matching content",
54
+ "save": "Save settings",
55
+ "types": {
56
+ "like": "Likes",
57
+ "repost": "Reposts",
58
+ "bookmark": "Bookmarks",
59
+ "reply": "Replies",
60
+ "checkin": "Check-ins"
61
+ }
62
+ },
63
+ "search": {
64
+ "title": "Search",
65
+ "placeholder": "Enter URL or search term",
66
+ "submit": "Search",
67
+ "noResults": "No results found"
68
+ },
69
+ "preview": {
70
+ "title": "Preview",
71
+ "subscribe": "Subscribe to this feed"
72
+ },
73
+ "error": {
74
+ "channelNotFound": "Channel not found",
75
+ "feedNotFound": "Feed not found",
76
+ "invalidUrl": "Invalid URL",
77
+ "invalidAction": "Invalid action"
78
+ }
79
+ }
80
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-microsub",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "microsub",
10
+ "reader",
11
+ "social-reader"
12
+ ],
13
+ "homepage": "https://github.com/rmdes/indiekit",
14
+ "author": {
15
+ "name": "Ricardo Mendes",
16
+ "url": "https://rmendes.net"
17
+ },
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "type": "module",
23
+ "main": "index.js",
24
+ "files": [
25
+ "lib",
26
+ "locales",
27
+ "views",
28
+ "index.js"
29
+ ],
30
+ "bugs": {
31
+ "url": "https://github.com/rmdes/indiekit/issues"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/rmdes/indiekit.git",
36
+ "directory": "packages/endpoint-microsub"
37
+ },
38
+ "dependencies": {
39
+ "@indiekit/error": "^1.0.0-beta.25",
40
+ "@indiekit/frontend": "^1.0.0-beta.25",
41
+ "@indiekit/util": "^1.0.0-beta.25",
42
+ "debug": "^4.3.2",
43
+ "express": "^5.0.0",
44
+ "feedparser": "^2.2.10",
45
+ "htmlparser2": "^9.0.0",
46
+ "ioredis": "^5.3.0",
47
+ "luxon": "^3.4.0",
48
+ "microformats-parser": "^2.0.0",
49
+ "sanitize-html": "^2.11.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,33 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="channel-new">
5
+ <a href="{{ request.baseUrl }}/channels" class="back-link">
6
+ {{ icon("arrow-left") }} {{ __("microsub.channels.title") }}
7
+ </a>
8
+
9
+ <form method="post" action="{{ request.baseUrl }}/channels/new">
10
+ {{ field({
11
+ label: {
12
+ text: __("microsub.channels.new")
13
+ },
14
+ input: {
15
+ id: "name",
16
+ name: "name",
17
+ required: true,
18
+ autocomplete: "off",
19
+ autofocus: true
20
+ }
21
+ }) }}
22
+
23
+ <div class="button-group">
24
+ {{ button({
25
+ text: __("microsub.channels.create")
26
+ }) }}
27
+ <a href="{{ request.baseUrl }}/channels" class="button button--secondary">
28
+ {{ __("Cancel") }}
29
+ </a>
30
+ </div>
31
+ </form>
32
+ </div>
33
+ {% endblock %}
@@ -0,0 +1,41 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="channel">
5
+ <header class="channel__header">
6
+ <a href="{{ request.baseUrl }}/channels" class="back-link">
7
+ {{ icon("arrow-left") }} {{ __("microsub.channels.title") }}
8
+ </a>
9
+ <div class="channel__actions">
10
+ <a href="{{ request.baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
11
+ {{ icon("settings") }} {{ __("microsub.channels.settings") }}
12
+ </a>
13
+ </div>
14
+ </header>
15
+
16
+ {% if items.length > 0 %}
17
+ <div class="timeline">
18
+ {% for item in items %}
19
+ {% include "partials/item-card.njk" %}
20
+ {% endfor %}
21
+ </div>
22
+
23
+ {% if paging %}
24
+ <nav class="timeline__paging" aria-label="Pagination">
25
+ {% if paging.before %}
26
+ <a href="?before={{ paging.before }}" class="button button--secondary">
27
+ {{ icon("arrow-left") }} {{ __("microsub.reader.newer") }}
28
+ </a>
29
+ {% endif %}
30
+ {% if paging.after %}
31
+ <a href="?after={{ paging.after }}" class="button button--secondary">
32
+ {{ __("microsub.reader.older") }} {{ icon("arrow-right") }}
33
+ </a>
34
+ {% endif %}
35
+ </nav>
36
+ {% endif %}
37
+ {% else %}
38
+ {{ prose({ text: __("microsub.timeline.empty") }) }}
39
+ {% endif %}
40
+ </div>
41
+ {% endblock %}
@@ -0,0 +1,61 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="compose">
5
+ <a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="back-link">
6
+ {{ icon("arrow-left") }} {{ __("Back") }}
7
+ </a>
8
+
9
+ {% if replyTo %}
10
+ <div class="compose__context">
11
+ {{ icon("reply") }} {{ __("microsub.compose.replyTo") }}: <a href="{{ replyTo }}">{{ replyTo }}</a>
12
+ </div>
13
+ {% endif %}
14
+
15
+ {% if likeOf %}
16
+ <div class="compose__context">
17
+ {{ icon("heart") }} {{ __("microsub.compose.likeOf") }}: <a href="{{ likeOf }}">{{ likeOf }}</a>
18
+ </div>
19
+ {% endif %}
20
+
21
+ {% if repostOf %}
22
+ <div class="compose__context">
23
+ {{ icon("repost") }} {{ __("microsub.compose.repostOf") }}: <a href="{{ repostOf }}">{{ repostOf }}</a>
24
+ </div>
25
+ {% endif %}
26
+
27
+ <form method="post" action="{{ request.baseUrl }}/compose">
28
+ {% if replyTo %}
29
+ <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
30
+ {% endif %}
31
+ {% if likeOf %}
32
+ <input type="hidden" name="like-of" value="{{ likeOf }}">
33
+ {% endif %}
34
+ {% if repostOf %}
35
+ <input type="hidden" name="repost-of" value="{{ repostOf }}">
36
+ {% endif %}
37
+
38
+ {% if not likeOf and not repostOf %}
39
+ {{ textarea({
40
+ label: {
41
+ text: __("microsub.compose.content"),
42
+ classes: "label--visuallyhidden"
43
+ },
44
+ id: "content",
45
+ name: "content",
46
+ rows: 5,
47
+ autofocus: true
48
+ }) }}
49
+ {% endif %}
50
+
51
+ <div class="button-group">
52
+ {{ button({
53
+ text: __("microsub.compose.submit")
54
+ }) }}
55
+ <a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="button button--secondary">
56
+ {{ __("microsub.compose.cancel") }}
57
+ </a>
58
+ </div>
59
+ </form>
60
+ </div>
61
+ {% endblock %}