@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,109 @@
1
+ /**
2
+ * Read state tracking utilities
3
+ * @module storage/read-state
4
+ */
5
+
6
+ import { markItemsRead, markItemsUnread, getUnreadCount } from "./items.js";
7
+
8
+ /**
9
+ * Mark entries as read for a user
10
+ * @param {object} application - Indiekit application
11
+ * @param {string} channelUid - Channel UID
12
+ * @param {Array} entries - Entry IDs to mark as read
13
+ * @param {string} userId - User ID
14
+ * @returns {Promise<number>} Number of entries marked
15
+ */
16
+ export async function markRead(application, channelUid, entries, userId) {
17
+ const channelsCollection = application.collections.get("microsub_channels");
18
+ const channel = await channelsCollection.findOne({ uid: channelUid });
19
+
20
+ if (!channel) {
21
+ return 0;
22
+ }
23
+
24
+ return markItemsRead(application, channel._id, entries, userId);
25
+ }
26
+
27
+ /**
28
+ * Mark entries as unread for a user
29
+ * @param {object} application - Indiekit application
30
+ * @param {string} channelUid - Channel UID
31
+ * @param {Array} entries - Entry IDs to mark as unread
32
+ * @param {string} userId - User ID
33
+ * @returns {Promise<number>} Number of entries marked
34
+ */
35
+ export async function markUnread(application, channelUid, entries, userId) {
36
+ const channelsCollection = application.collections.get("microsub_channels");
37
+ const channel = await channelsCollection.findOne({ uid: channelUid });
38
+
39
+ if (!channel) {
40
+ return 0;
41
+ }
42
+
43
+ return markItemsUnread(application, channel._id, entries, userId);
44
+ }
45
+
46
+ /**
47
+ * Get unread count for a channel
48
+ * @param {object} application - Indiekit application
49
+ * @param {string} channelUid - Channel UID
50
+ * @param {string} userId - User ID
51
+ * @returns {Promise<number>} Unread count
52
+ */
53
+ export async function getChannelUnreadCount(application, channelUid, userId) {
54
+ const channelsCollection = application.collections.get("microsub_channels");
55
+ const channel = await channelsCollection.findOne({ uid: channelUid });
56
+
57
+ if (!channel) {
58
+ return 0;
59
+ }
60
+
61
+ return getUnreadCount(application, channel._id, userId);
62
+ }
63
+
64
+ /**
65
+ * Get unread counts for all channels
66
+ * @param {object} application - Indiekit application
67
+ * @param {string} userId - User ID
68
+ * @returns {Promise<Map>} Map of channel UID to unread count
69
+ */
70
+ export async function getAllUnreadCounts(application, userId) {
71
+ const channelsCollection = application.collections.get("microsub_channels");
72
+ const itemsCollection = application.collections.get("microsub_items");
73
+
74
+ // Aggregate unread counts per channel
75
+ const pipeline = [
76
+ {
77
+ $match: {
78
+ readBy: { $ne: userId },
79
+ },
80
+ },
81
+ {
82
+ $group: {
83
+ _id: "$channelId",
84
+ count: { $sum: 1 },
85
+ },
86
+ },
87
+ ];
88
+
89
+ const results = await itemsCollection.aggregate(pipeline).toArray();
90
+
91
+ // Get channel UIDs
92
+ const channelIds = results.map((r) => r._id);
93
+ const channels = await channelsCollection
94
+ .find({ _id: { $in: channelIds } })
95
+ .toArray();
96
+
97
+ const channelMap = new Map(channels.map((c) => [c._id.toString(), c.uid]));
98
+
99
+ // Build result map
100
+ const unreadCounts = new Map();
101
+ for (const result of results) {
102
+ const uid = channelMap.get(result._id.toString());
103
+ if (uid) {
104
+ unreadCounts.set(uid, result.count);
105
+ }
106
+ }
107
+
108
+ return unreadCounts;
109
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * jf2 utility functions for Microsub
3
+ * @module utils/jf2
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ /**
9
+ * Generate a unique ID for an item based on feed URL and item identifier
10
+ * @param {string} feedUrl - Feed URL
11
+ * @param {string} itemId - Item ID or URL
12
+ * @returns {string} Unique item ID
13
+ */
14
+ export function generateItemUid(feedUrl, itemId) {
15
+ const input = `${feedUrl}:${itemId}`;
16
+ return createHash("sha256").update(input).digest("hex").slice(0, 24);
17
+ }
18
+
19
+ /**
20
+ * Generate a random channel UID
21
+ * @returns {string} 24-character random string
22
+ */
23
+ export function generateChannelUid() {
24
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
25
+ let result = "";
26
+ for (let index = 0; index < 24; index++) {
27
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
28
+ }
29
+ return result;
30
+ }
31
+
32
+ /**
33
+ * Create a jf2 Item from normalized feed data
34
+ * @param {object} data - Normalized item data
35
+ * @param {object} source - Feed source metadata
36
+ * @returns {object} jf2 Item object
37
+ */
38
+ export function createJf2Item(data, source) {
39
+ return {
40
+ type: "entry",
41
+ uid: data.uid,
42
+ url: data.url,
43
+ name: data.name || undefined,
44
+ content: data.content || undefined,
45
+ summary: data.summary || undefined,
46
+ published: data.published,
47
+ updated: data.updated || undefined,
48
+ author: data.author || undefined,
49
+ category: data.category || [],
50
+ photo: data.photo || [],
51
+ video: data.video || [],
52
+ audio: data.audio || [],
53
+ // Interaction types
54
+ "like-of": data.likeOf || [],
55
+ "repost-of": data.repostOf || [],
56
+ "bookmark-of": data.bookmarkOf || [],
57
+ "in-reply-to": data.inReplyTo || [],
58
+ // Internal properties (prefixed with _)
59
+ _id: data._id,
60
+ _is_read: data._is_read || false,
61
+ _source: source,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Create a jf2 Card (author/person)
67
+ * @param {object} data - Author data
68
+ * @returns {object} jf2 Card object
69
+ */
70
+ export function createJf2Card(data) {
71
+ if (!data) return;
72
+
73
+ return {
74
+ type: "card",
75
+ name: data.name || undefined,
76
+ url: data.url || undefined,
77
+ photo: data.photo || undefined,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Create a jf2 Content object
83
+ * @param {string} text - Plain text content
84
+ * @param {string} html - HTML content
85
+ * @returns {object|undefined} jf2 Content object
86
+ */
87
+ export function createJf2Content(text, html) {
88
+ if (!text && !html) return;
89
+
90
+ return {
91
+ text: text || stripHtml(html),
92
+ html: html || undefined,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Strip HTML tags from string
98
+ * @param {string} html - HTML string
99
+ * @returns {string} Plain text
100
+ */
101
+ export function stripHtml(html) {
102
+ if (!html) return "";
103
+ return html.replaceAll(/<[^>]*>/g, "").trim();
104
+ }
105
+
106
+ /**
107
+ * Create a jf2 Feed response
108
+ * @param {object} options - Feed options
109
+ * @param {Array} options.items - Array of jf2 items
110
+ * @param {object} options.paging - Pagination cursors
111
+ * @returns {object} jf2 Feed object
112
+ */
113
+ export function createJf2Feed({ items, paging }) {
114
+ const feed = {
115
+ items: items || [],
116
+ };
117
+
118
+ if (paging) {
119
+ feed.paging = {};
120
+ if (paging.before) feed.paging.before = paging.before;
121
+ if (paging.after) feed.paging.after = paging.after;
122
+ }
123
+
124
+ return feed;
125
+ }
126
+
127
+ /**
128
+ * Create a Channel response object
129
+ * @param {object} channel - Channel data
130
+ * @param {number} unreadCount - Number of unread items
131
+ * @returns {object} Channel object for API response
132
+ */
133
+ export function createChannelResponse(channel, unreadCount = 0) {
134
+ return {
135
+ uid: channel.uid,
136
+ name: channel.name,
137
+ unread: unreadCount > 0 ? unreadCount : false,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Create a Feed response object
143
+ * @param {object} feed - Feed data
144
+ * @returns {object} Feed object for API response
145
+ */
146
+ export function createFeedResponse(feed) {
147
+ return {
148
+ type: "feed",
149
+ url: feed.url,
150
+ name: feed.title || undefined,
151
+ photo: feed.photo || undefined,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Detect interaction type from item properties
157
+ * @param {object} item - jf2 item
158
+ * @returns {string|undefined} Interaction type
159
+ */
160
+ export function detectInteractionType(item) {
161
+ if (item["like-of"]?.length > 0 || item.likeOf?.length > 0) return "like";
162
+ if (item["repost-of"]?.length > 0 || item.repostOf?.length > 0)
163
+ return "repost";
164
+ if (item["bookmark-of"]?.length > 0 || item.bookmarkOf?.length > 0)
165
+ return "bookmark";
166
+ if (item["in-reply-to"]?.length > 0 || item.inReplyTo?.length > 0)
167
+ return "reply";
168
+ if (item.checkin) return "checkin";
169
+ return;
170
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Cursor-based pagination utilities for Microsub
3
+ * @module utils/pagination
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ /**
9
+ * Encode a cursor from timestamp and ID
10
+ * @param {Date} timestamp - Item timestamp
11
+ * @param {string} id - Item ID
12
+ * @returns {string} Base64-encoded cursor
13
+ */
14
+ export function encodeCursor(timestamp, id) {
15
+ const data = {
16
+ t: timestamp instanceof Date ? timestamp.toISOString() : timestamp,
17
+ i: id.toString(),
18
+ };
19
+ return Buffer.from(JSON.stringify(data)).toString("base64url");
20
+ }
21
+
22
+ /**
23
+ * Decode a cursor string
24
+ * @param {string} cursor - Base64-encoded cursor
25
+ * @returns {object|null} Decoded cursor with timestamp and id
26
+ */
27
+ export function decodeCursor(cursor) {
28
+ if (!cursor) return;
29
+
30
+ try {
31
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
32
+ const data = JSON.parse(decoded);
33
+ return {
34
+ timestamp: new Date(data.t),
35
+ id: data.i,
36
+ };
37
+ } catch {
38
+ return;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Build MongoDB query for cursor-based pagination
44
+ * @param {object} options - Pagination options
45
+ * @param {string} [options.before] - Before cursor
46
+ * @param {string} [options.after] - After cursor
47
+ * @param {object} [options.baseQuery] - Base query to extend
48
+ * @returns {object} MongoDB query object
49
+ */
50
+ export function buildPaginationQuery({ before, after, baseQuery = {} }) {
51
+ const query = { ...baseQuery };
52
+
53
+ if (before) {
54
+ const cursor = decodeCursor(before);
55
+ if (cursor) {
56
+ // Items newer than cursor (for scrolling up)
57
+ query.$or = [
58
+ { published: { $gt: cursor.timestamp } },
59
+ {
60
+ published: cursor.timestamp,
61
+ _id: { $gt: new ObjectId(cursor.id) },
62
+ },
63
+ ];
64
+ }
65
+ } else if (after) {
66
+ const cursor = decodeCursor(after);
67
+ if (cursor) {
68
+ // Items older than cursor (for scrolling down)
69
+ query.$or = [
70
+ { published: { $lt: cursor.timestamp } },
71
+ {
72
+ published: cursor.timestamp,
73
+ _id: { $lt: new ObjectId(cursor.id) },
74
+ },
75
+ ];
76
+ }
77
+ }
78
+
79
+ return query;
80
+ }
81
+
82
+ /**
83
+ * Build sort options for cursor pagination
84
+ * @param {string} [before] - Before cursor (ascending order)
85
+ * @returns {object} MongoDB sort object
86
+ */
87
+ export function buildPaginationSort(before) {
88
+ // When using 'before', we fetch newer items, so sort ascending then reverse
89
+ // Otherwise, sort descending (newest first)
90
+ if (before) {
91
+ return { published: 1, _id: 1 };
92
+ }
93
+ return { published: -1, _id: -1 };
94
+ }
95
+
96
+ /**
97
+ * Generate pagination cursors from items
98
+ * @param {Array} items - Array of items
99
+ * @param {number} limit - Items per page
100
+ * @param {boolean} hasMore - Whether more items exist
101
+ * @param {string} [before] - Original before cursor
102
+ * @returns {object} Pagination object with before/after cursors
103
+ */
104
+ export function generatePagingCursors(items, limit, hasMore, before) {
105
+ if (!items || items.length === 0) {
106
+ return {};
107
+ }
108
+
109
+ const paging = {};
110
+
111
+ // If we fetched with 'before', results are in ascending order
112
+ // Reverse them and set cursors accordingly
113
+ if (before) {
114
+ items.reverse();
115
+ // There are older items (the direction we came from)
116
+ paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id);
117
+ if (hasMore) {
118
+ // There are newer items ahead
119
+ paging.before = encodeCursor(items[0].published, items[0]._id);
120
+ }
121
+ } else {
122
+ // Normal descending order
123
+ if (hasMore) {
124
+ // There are older items
125
+ paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id);
126
+ }
127
+ // If we have items, there might be newer ones
128
+ if (items.length > 0) {
129
+ paging.before = encodeCursor(items[0].published, items[0]._id);
130
+ }
131
+ }
132
+
133
+ return paging;
134
+ }
135
+
136
+ /**
137
+ * Default pagination limit
138
+ */
139
+ export const DEFAULT_LIMIT = 20;
140
+
141
+ /**
142
+ * Maximum pagination limit
143
+ */
144
+ export const MAX_LIMIT = 100;
145
+
146
+ /**
147
+ * Parse and validate limit parameter
148
+ * @param {string|number} limit - Requested limit
149
+ * @returns {number} Validated limit
150
+ */
151
+ export function parseLimit(limit) {
152
+ const parsed = Number.parseInt(limit, 10);
153
+ if (Number.isNaN(parsed) || parsed < 1) {
154
+ return DEFAULT_LIMIT;
155
+ }
156
+ return Math.min(parsed, MAX_LIMIT);
157
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Input validation utilities for Microsub
3
+ * @module utils/validation
4
+ */
5
+
6
+ import { IndiekitError } from "@indiekit/error";
7
+
8
+ /**
9
+ * Valid Microsub actions
10
+ */
11
+ export const VALID_ACTIONS = [
12
+ "channels",
13
+ "timeline",
14
+ "follow",
15
+ "unfollow",
16
+ "search",
17
+ "preview",
18
+ "mute",
19
+ "unmute",
20
+ "block",
21
+ "unblock",
22
+ "events",
23
+ ];
24
+
25
+ /**
26
+ * Valid channel methods
27
+ */
28
+ export const VALID_CHANNEL_METHODS = ["delete", "order"];
29
+
30
+ /**
31
+ * Valid timeline methods
32
+ */
33
+ export const VALID_TIMELINE_METHODS = ["mark_read", "mark_unread", "remove"];
34
+
35
+ /**
36
+ * Valid exclude types for channel filtering
37
+ */
38
+ export const VALID_EXCLUDE_TYPES = [
39
+ "like",
40
+ "repost",
41
+ "bookmark",
42
+ "reply",
43
+ "checkin",
44
+ ];
45
+
46
+ /**
47
+ * Validate action parameter
48
+ * @param {string} action - Action to validate
49
+ * @throws {IndiekitError} If action is invalid
50
+ */
51
+ export function validateAction(action) {
52
+ if (!action) {
53
+ throw new IndiekitError("Missing required parameter: action", {
54
+ status: 400,
55
+ });
56
+ }
57
+
58
+ if (!VALID_ACTIONS.includes(action)) {
59
+ throw new IndiekitError(`Invalid action: ${action}`, {
60
+ status: 400,
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Validate channel UID
67
+ * @param {string} channel - Channel UID to validate
68
+ * @param {boolean} [required] - Whether channel is required
69
+ * @throws {IndiekitError} If channel is invalid
70
+ */
71
+ export function validateChannel(channel, required = true) {
72
+ if (required && !channel) {
73
+ throw new IndiekitError("Missing required parameter: channel", {
74
+ status: 400,
75
+ });
76
+ }
77
+
78
+ if (channel && typeof channel !== "string") {
79
+ throw new IndiekitError("Invalid channel parameter", {
80
+ status: 400,
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Validate URL parameter
87
+ * @param {string} url - URL to validate
88
+ * @param {string} [paramName] - Parameter name for error message
89
+ * @param parameterName
90
+ * @throws {IndiekitError} If URL is invalid
91
+ */
92
+ export function validateUrl(url, parameterName = "url") {
93
+ if (!url) {
94
+ throw new IndiekitError(`Missing required parameter: ${parameterName}`, {
95
+ status: 400,
96
+ });
97
+ }
98
+
99
+ try {
100
+ new URL(url);
101
+ } catch {
102
+ throw new IndiekitError(`Invalid URL: ${url}`, {
103
+ status: 400,
104
+ });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validate entry/entries parameter
110
+ * @param {string|Array} entry - Entry ID(s) to validate
111
+ * @returns {Array} Array of entry IDs
112
+ * @throws {IndiekitError} If entry is invalid
113
+ */
114
+ export function validateEntries(entry) {
115
+ if (!entry) {
116
+ throw new IndiekitError("Missing required parameter: entry", {
117
+ status: 400,
118
+ });
119
+ }
120
+
121
+ // Normalize to array
122
+ const entries = Array.isArray(entry) ? entry : [entry];
123
+
124
+ if (entries.length === 0) {
125
+ throw new IndiekitError("Entry parameter cannot be empty", {
126
+ status: 400,
127
+ });
128
+ }
129
+
130
+ return entries;
131
+ }
132
+
133
+ /**
134
+ * Validate channel name
135
+ * @param {string} name - Channel name to validate
136
+ * @throws {IndiekitError} If name is invalid
137
+ */
138
+ export function validateChannelName(name) {
139
+ if (!name || typeof name !== "string") {
140
+ throw new IndiekitError("Missing required parameter: name", {
141
+ status: 400,
142
+ });
143
+ }
144
+
145
+ if (name.length > 100) {
146
+ throw new IndiekitError("Channel name must be 100 characters or less", {
147
+ status: 400,
148
+ });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Validate exclude types array
154
+ * @param {Array} types - Array of exclude types
155
+ * @returns {Array} Validated exclude types
156
+ */
157
+ export function validateExcludeTypes(types) {
158
+ if (!types || !Array.isArray(types)) {
159
+ return [];
160
+ }
161
+
162
+ return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
163
+ }
164
+
165
+ /**
166
+ * Validate regex pattern
167
+ * @param {string} pattern - Regex pattern to validate
168
+ * @returns {string|null} Valid pattern or null
169
+ */
170
+ export function validateExcludeRegex(pattern) {
171
+ if (!pattern || typeof pattern !== "string") {
172
+ return;
173
+ }
174
+
175
+ try {
176
+ new RegExp(pattern);
177
+ return pattern;
178
+ } catch {
179
+ return;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Parse array parameter from request
185
+ * Handles both array[] and array[0], array[1] formats
186
+ * @param {object} body - Request body
187
+ * @param {string} paramName - Parameter name
188
+ * @param parameterName
189
+ * @returns {Array} Parsed array
190
+ */
191
+ export function parseArrayParameter(body, parameterName) {
192
+ // Direct array
193
+ if (Array.isArray(body[parameterName])) {
194
+ return body[parameterName];
195
+ }
196
+
197
+ // Single value
198
+ if (body[parameterName]) {
199
+ return [body[parameterName]];
200
+ }
201
+
202
+ // Indexed values (param[0], param[1], ...)
203
+ const result = [];
204
+ let index = 0;
205
+ while (body[`${parameterName}[${index}]`] !== undefined) {
206
+ result.push(body[`${parameterName}[${index}]`]);
207
+ index++;
208
+ }
209
+
210
+ // Array notation (param[])
211
+ if (body[`${parameterName}[]`]) {
212
+ const values = body[`${parameterName}[]`];
213
+ return Array.isArray(values) ? values : [values];
214
+ }
215
+
216
+ return result;
217
+ }