@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,108 @@
1
+ /**
2
+ * Follow/unfollow controller
3
+ * @module controllers/follow
4
+ */
5
+
6
+ import { IndiekitError } from "@indiekit/error";
7
+
8
+ import { refreshFeedNow } from "../polling/scheduler.js";
9
+ import { getChannel } from "../storage/channels.js";
10
+ import {
11
+ createFeed,
12
+ deleteFeed,
13
+ getFeedsForChannel,
14
+ } from "../storage/feeds.js";
15
+ import { createFeedResponse } from "../utils/jf2.js";
16
+ import { validateChannel, validateUrl } from "../utils/validation.js";
17
+
18
+ /**
19
+ * List followed feeds for a channel
20
+ * GET ?action=follow&channel=<uid>
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
+ const { channel } = request.query;
28
+
29
+ validateChannel(channel);
30
+
31
+ const channelDocument = await getChannel(application, channel, userId);
32
+ if (!channelDocument) {
33
+ throw new IndiekitError("Channel not found", { status: 404 });
34
+ }
35
+
36
+ const feeds = await getFeedsForChannel(application, channelDocument._id);
37
+ const items = feeds.map((feed) => createFeedResponse(feed));
38
+
39
+ response.json({ items });
40
+ }
41
+
42
+ /**
43
+ * Follow a feed URL
44
+ * POST ?action=follow
45
+ * @param {object} request - Express request
46
+ * @param {object} response - Express response
47
+ */
48
+ export async function follow(request, response) {
49
+ const { application } = request.app.locals;
50
+ const userId = request.session?.userId;
51
+ const { channel, url } = request.body;
52
+
53
+ validateChannel(channel);
54
+ validateUrl(url);
55
+
56
+ const channelDocument = await getChannel(application, channel, userId);
57
+ if (!channelDocument) {
58
+ throw new IndiekitError("Channel not found", { status: 404 });
59
+ }
60
+
61
+ // Create feed subscription
62
+ const feed = await createFeed(application, {
63
+ channelId: channelDocument._id,
64
+ url,
65
+ title: undefined, // Will be populated on first fetch
66
+ photo: undefined,
67
+ });
68
+
69
+ // Trigger immediate fetch in background (don't await)
70
+ refreshFeedNow(application, feed._id).catch((error) => {
71
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
72
+ });
73
+
74
+ // TODO: Attempt WebSub subscription
75
+
76
+ response.status(201).json(createFeedResponse(feed));
77
+ }
78
+
79
+ /**
80
+ * Unfollow a feed URL
81
+ * POST ?action=unfollow
82
+ * @param {object} request - Express request
83
+ * @param {object} response - Express response
84
+ */
85
+ export async function unfollow(request, response) {
86
+ const { application } = request.app.locals;
87
+ const userId = request.session?.userId;
88
+ const { channel, url } = request.body;
89
+
90
+ validateChannel(channel);
91
+ validateUrl(url);
92
+
93
+ const channelDocument = await getChannel(application, channel, userId);
94
+ if (!channelDocument) {
95
+ throw new IndiekitError("Channel not found", { status: 404 });
96
+ }
97
+
98
+ const deleted = await deleteFeed(application, channelDocument._id, url);
99
+ if (!deleted) {
100
+ throw new IndiekitError("Feed not found", { status: 404 });
101
+ }
102
+
103
+ // TODO: Cancel WebSub subscription if active
104
+
105
+ response.json({ result: "ok" });
106
+ }
107
+
108
+ export const followController = { list, follow, unfollow };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Main Microsub action router
3
+ * @module controllers/microsub
4
+ */
5
+
6
+ import { IndiekitError } from "@indiekit/error";
7
+
8
+ import { validateAction } from "../utils/validation.js";
9
+
10
+ import { list as listBlocked, block, unblock } from "./block.js";
11
+ import { list as listChannels, action as channelAction } from "./channels.js";
12
+ import { stream as eventsStream } from "./events.js";
13
+ import { list as listFollows, follow, unfollow } from "./follow.js";
14
+ import { list as listMuted, mute, unmute } from "./mute.js";
15
+ import { get as getPreview, preview } from "./preview.js";
16
+ import { discover, search } from "./search.js";
17
+ import { get as getTimeline, action as timelineAction } from "./timeline.js";
18
+
19
+ /**
20
+ * Route GET requests to appropriate action handler
21
+ * @param {object} request - Express request
22
+ * @param {object} response - Express response
23
+ * @param {Function} next - Express next function
24
+ */
25
+ export async function get(request, response, next) {
26
+ try {
27
+ const { action } = request.query;
28
+ validateAction(action);
29
+
30
+ switch (action) {
31
+ case "channels": {
32
+ return listChannels(request, response);
33
+ }
34
+
35
+ case "timeline": {
36
+ return getTimeline(request, response);
37
+ }
38
+
39
+ case "follow": {
40
+ return listFollows(request, response);
41
+ }
42
+
43
+ case "preview": {
44
+ return getPreview(request, response);
45
+ }
46
+
47
+ case "mute": {
48
+ return listMuted(request, response);
49
+ }
50
+
51
+ case "block": {
52
+ return listBlocked(request, response);
53
+ }
54
+
55
+ case "events": {
56
+ return eventsStream(request, response);
57
+ }
58
+
59
+ case "search": {
60
+ // Search is typically POST, but GET is allowed for feed discovery
61
+ return discover(request, response);
62
+ }
63
+
64
+ default: {
65
+ throw new IndiekitError(`Unsupported GET action: ${action}`, {
66
+ status: 400,
67
+ });
68
+ }
69
+ }
70
+ } catch (error) {
71
+ next(error);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Route POST requests to appropriate action handler
77
+ * @param {object} request - Express request
78
+ * @param {object} response - Express response
79
+ * @param {Function} next - Express next function
80
+ */
81
+ export async function post(request, response, next) {
82
+ try {
83
+ const action = request.body.action || request.query.action;
84
+ validateAction(action);
85
+
86
+ switch (action) {
87
+ case "channels": {
88
+ return channelAction(request, response);
89
+ }
90
+
91
+ case "timeline": {
92
+ return timelineAction(request, response);
93
+ }
94
+
95
+ case "follow": {
96
+ return follow(request, response);
97
+ }
98
+
99
+ case "unfollow": {
100
+ return unfollow(request, response);
101
+ }
102
+
103
+ case "search": {
104
+ return search(request, response);
105
+ }
106
+
107
+ case "preview": {
108
+ return preview(request, response);
109
+ }
110
+
111
+ case "mute": {
112
+ return mute(request, response);
113
+ }
114
+
115
+ case "unmute": {
116
+ return unmute(request, response);
117
+ }
118
+
119
+ case "block": {
120
+ return block(request, response);
121
+ }
122
+
123
+ case "unblock": {
124
+ return unblock(request, response);
125
+ }
126
+
127
+ default: {
128
+ throw new IndiekitError(`Unsupported POST action: ${action}`, {
129
+ status: 400,
130
+ });
131
+ }
132
+ }
133
+ } catch (error) {
134
+ next(error);
135
+ }
136
+ }
137
+
138
+ export const microsubController = { get, post };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Mute controller
3
+ * @module controllers/mute
4
+ */
5
+
6
+ import { IndiekitError } from "@indiekit/error";
7
+
8
+ import { validateChannel, validateUrl } from "../utils/validation.js";
9
+
10
+ /**
11
+ * Get muted collection
12
+ * @param {object} application - Indiekit application
13
+ * @returns {object} MongoDB collection
14
+ */
15
+ function getCollection(application) {
16
+ return application.collections.get("microsub_muted");
17
+ }
18
+
19
+ /**
20
+ * List muted URLs for a channel
21
+ * GET ?action=mute&channel=<uid>
22
+ * @param {object} request - Express request
23
+ * @param {object} response - Express response
24
+ */
25
+ export async function list(request, response) {
26
+ const { application } = request.app.locals;
27
+ const userId = request.session?.userId;
28
+ const { channel } = request.query;
29
+
30
+ // Channel can be "global" or a specific channel UID
31
+ const isGlobal = channel === "global";
32
+
33
+ const collection = getCollection(application);
34
+ const filter = { userId };
35
+
36
+ if (!isGlobal && channel) {
37
+ // Get channel-specific mutes
38
+ const channelsCollection = application.collections.get("microsub_channels");
39
+ const channelDocument = await channelsCollection.findOne({ uid: channel });
40
+ if (channelDocument) {
41
+ filter.channelId = channelDocument._id;
42
+ }
43
+ }
44
+ // For global mutes, we query without channelId (matches all channels)
45
+
46
+ // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
47
+ const muted = await collection.find(filter).toArray();
48
+ const items = muted.map((m) => ({ url: m.url }));
49
+
50
+ response.json({ items });
51
+ }
52
+
53
+ /**
54
+ * Mute a URL
55
+ * POST ?action=mute
56
+ * @param {object} request - Express request
57
+ * @param {object} response - Express response
58
+ */
59
+ export async function mute(request, response) {
60
+ const { application } = request.app.locals;
61
+ const userId = request.session?.userId;
62
+ const { channel, url } = request.body;
63
+
64
+ validateUrl(url);
65
+
66
+ const collection = getCollection(application);
67
+ const isGlobal = channel === "global" || !channel;
68
+
69
+ let channelId;
70
+ if (!isGlobal) {
71
+ validateChannel(channel);
72
+ const channelsCollection = application.collections.get("microsub_channels");
73
+ const channelDocument = await channelsCollection.findOne({ uid: channel });
74
+ if (!channelDocument) {
75
+ throw new IndiekitError("Channel not found", { status: 404 });
76
+ }
77
+ channelId = channelDocument._id;
78
+ }
79
+
80
+ // Check if already muted
81
+ const existing = await collection.findOne({ userId, channelId, url });
82
+ if (!existing) {
83
+ await collection.insertOne({
84
+ userId,
85
+ channelId,
86
+ url,
87
+ createdAt: new Date(),
88
+ });
89
+ }
90
+
91
+ response.json({ result: "ok" });
92
+ }
93
+
94
+ /**
95
+ * Unmute a URL
96
+ * POST ?action=unmute
97
+ * @param {object} request - Express request
98
+ * @param {object} response - Express response
99
+ */
100
+ export async function unmute(request, response) {
101
+ const { application } = request.app.locals;
102
+ const userId = request.session?.userId;
103
+ const { channel, url } = request.body;
104
+
105
+ validateUrl(url);
106
+
107
+ const collection = getCollection(application);
108
+ const isGlobal = channel === "global" || !channel;
109
+
110
+ let channelId;
111
+ if (!isGlobal) {
112
+ const channelsCollection = application.collections.get("microsub_channels");
113
+ const channelDocument = await channelsCollection.findOne({ uid: channel });
114
+ if (channelDocument) {
115
+ channelId = channelDocument._id;
116
+ }
117
+ }
118
+
119
+ await collection.deleteOne({ userId, channelId, url });
120
+
121
+ response.json({ result: "ok" });
122
+ }
123
+
124
+ export const muteController = { list, mute, unmute };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Preview controller
3
+ * @module controllers/preview
4
+ */
5
+
6
+ import { IndiekitError } from "@indiekit/error";
7
+
8
+ import { fetchAndParseFeed } from "../feeds/fetcher.js";
9
+ import { validateUrl } from "../utils/validation.js";
10
+
11
+ const MAX_PREVIEW_ITEMS = 10;
12
+
13
+ /**
14
+ * Fetch and preview a feed
15
+ * @param {string} url - Feed URL
16
+ * @returns {Promise<object>} Preview response
17
+ */
18
+ async function fetchPreview(url) {
19
+ try {
20
+ const parsed = await fetchAndParseFeed(url);
21
+
22
+ // Return feed metadata and sample items
23
+ return {
24
+ type: "feed",
25
+ url: parsed.url,
26
+ name: parsed.name,
27
+ photo: parsed.photo,
28
+ items: parsed.items.slice(0, MAX_PREVIEW_ITEMS),
29
+ };
30
+ } catch (error) {
31
+ throw new IndiekitError(`Failed to preview feed: ${error.message}`, {
32
+ status: 502,
33
+ });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Preview a feed URL (GET)
39
+ * GET ?action=preview&url=<feed>
40
+ * @param {object} request - Express request
41
+ * @param {object} response - Express response
42
+ */
43
+ export async function get(request, response) {
44
+ const { url } = request.query;
45
+
46
+ validateUrl(url);
47
+
48
+ const preview = await fetchPreview(url);
49
+ response.json(preview);
50
+ }
51
+
52
+ /**
53
+ * Preview a feed URL (POST)
54
+ * POST ?action=preview
55
+ * @param {object} request - Express request
56
+ * @param {object} response - Express response
57
+ */
58
+ export async function preview(request, response) {
59
+ const { url } = request.body;
60
+
61
+ validateUrl(url);
62
+
63
+ const previewData = await fetchPreview(url);
64
+ response.json(previewData);
65
+ }
66
+
67
+ export const previewController = { get, preview };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Reader UI controller
3
+ * @module controllers/reader
4
+ */
5
+
6
+ import {
7
+ getChannels,
8
+ getChannel,
9
+ createChannel,
10
+ updateChannelSettings,
11
+ } from "../storage/channels.js";
12
+ import { getTimelineItems, getItemById } from "../storage/items.js";
13
+ import {
14
+ validateChannelName,
15
+ validateExcludeTypes,
16
+ validateExcludeRegex,
17
+ } from "../utils/validation.js";
18
+
19
+ /**
20
+ * Reader index - redirect to channels
21
+ * @param {object} request - Express request
22
+ * @param {object} response - Express response
23
+ */
24
+ export async function index(request, response) {
25
+ response.redirect(`${request.baseUrl}/channels`);
26
+ }
27
+
28
+ /**
29
+ * List channels
30
+ * @param {object} request - Express request
31
+ * @param {object} response - Express response
32
+ */
33
+ export async function channels(request, response) {
34
+ const { application } = request.app.locals;
35
+ const userId = request.session?.userId;
36
+
37
+ const channelList = await getChannels(application, userId);
38
+
39
+ response.render("reader", {
40
+ title: request.__("microsub.reader.title"),
41
+ channels: channelList,
42
+ });
43
+ }
44
+
45
+ /**
46
+ * New channel form
47
+ * @param {object} request - Express request
48
+ * @param {object} response - Express response
49
+ */
50
+ export async function newChannel(request, response) {
51
+ response.render("channel-new", {
52
+ title: request.__("microsub.channels.new"),
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Create channel
58
+ * @param {object} request - Express request
59
+ * @param {object} response - Express response
60
+ */
61
+ export async function createChannelAction(request, response) {
62
+ const { application } = request.app.locals;
63
+ const userId = request.session?.userId;
64
+ const { name } = request.body;
65
+
66
+ validateChannelName(name);
67
+
68
+ await createChannel(application, { name, userId });
69
+
70
+ response.redirect(`${request.baseUrl}/channels`);
71
+ }
72
+
73
+ /**
74
+ * View channel timeline
75
+ * @param {object} request - Express request
76
+ * @param {object} response - Express response
77
+ */
78
+ export async function channel(request, response) {
79
+ const { application } = request.app.locals;
80
+ const userId = request.session?.userId;
81
+ const { uid } = request.params;
82
+ const { before, after } = request.query;
83
+
84
+ const channelDocument = await getChannel(application, uid, userId);
85
+ if (!channelDocument) {
86
+ return response.status(404).render("404");
87
+ }
88
+
89
+ const timeline = await getTimelineItems(application, channelDocument._id, {
90
+ before,
91
+ after,
92
+ userId,
93
+ });
94
+
95
+ response.render("channel", {
96
+ title: channelDocument.name,
97
+ channel: channelDocument,
98
+ items: timeline.items,
99
+ paging: timeline.paging,
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Channel settings form
105
+ * @param {object} request - Express request
106
+ * @param {object} response - Express response
107
+ */
108
+ export async function settings(request, response) {
109
+ const { application } = request.app.locals;
110
+ const userId = request.session?.userId;
111
+ const { uid } = request.params;
112
+
113
+ const channelDocument = await getChannel(application, uid, userId);
114
+ if (!channelDocument) {
115
+ return response.status(404).render("404");
116
+ }
117
+
118
+ response.render("settings", {
119
+ title: request.__("microsub.settings.title", {
120
+ channel: channelDocument.name,
121
+ }),
122
+ channel: channelDocument,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Update channel settings
128
+ * @param {object} request - Express request
129
+ * @param {object} response - Express response
130
+ */
131
+ export async function updateSettings(request, response) {
132
+ const { application } = request.app.locals;
133
+ const userId = request.session?.userId;
134
+ const { uid } = request.params;
135
+ const { excludeTypes, excludeRegex } = request.body;
136
+
137
+ const channelDocument = await getChannel(application, uid, userId);
138
+ if (!channelDocument) {
139
+ return response.status(404).render("404");
140
+ }
141
+
142
+ const validatedTypes = validateExcludeTypes(
143
+ Array.isArray(excludeTypes) ? excludeTypes : [excludeTypes].filter(Boolean),
144
+ );
145
+ const validatedRegex = validateExcludeRegex(excludeRegex);
146
+
147
+ await updateChannelSettings(
148
+ application,
149
+ uid,
150
+ {
151
+ excludeTypes: validatedTypes,
152
+ excludeRegex: validatedRegex,
153
+ },
154
+ userId,
155
+ );
156
+
157
+ response.redirect(`${request.baseUrl}/channels/${uid}`);
158
+ }
159
+
160
+ /**
161
+ * View single item
162
+ * @param {object} request - Express request
163
+ * @param {object} response - Express response
164
+ */
165
+ export async function item(request, response) {
166
+ const { application } = request.app.locals;
167
+ const userId = request.session?.userId;
168
+ const { id } = request.params;
169
+
170
+ const itemDocument = await getItemById(application, id, userId);
171
+ if (!itemDocument) {
172
+ return response.status(404).render("404");
173
+ }
174
+
175
+ response.render("item", {
176
+ title: itemDocument.name || "Item",
177
+ item: itemDocument,
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Compose response form
183
+ * @param {object} request - Express request
184
+ * @param {object} response - Express response
185
+ */
186
+ export async function compose(request, response) {
187
+ const { replyTo, likeOf, repostOf } = request.query;
188
+
189
+ response.render("compose", {
190
+ title: request.__("microsub.compose.title"),
191
+ replyTo,
192
+ likeOf,
193
+ repostOf,
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Submit composed response
199
+ * @param {object} request - Express request
200
+ * @param {object} response - Express response
201
+ */
202
+ export async function submitCompose(request, response) {
203
+ // TODO: Submit via Micropub
204
+ response.redirect(`${request.baseUrl}/channels`);
205
+ }
206
+
207
+ export const readerController = {
208
+ index,
209
+ channels,
210
+ newChannel,
211
+ createChannel: createChannelAction,
212
+ channel,
213
+ settings,
214
+ updateSettings,
215
+ item,
216
+ compose,
217
+ submitCompose,
218
+ };