@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,43 @@
1
+ /**
2
+ * JSON Feed parser
3
+ * @module feeds/jsonfeed
4
+ */
5
+
6
+ import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer.js";
7
+
8
+ /**
9
+ * Parse JSON Feed content
10
+ * @param {string} content - JSON Feed content
11
+ * @param {string} feedUrl - URL of the feed
12
+ * @returns {Promise<object>} Parsed feed with metadata and items
13
+ */
14
+ export async function parseJsonFeed(content, feedUrl) {
15
+ let feed;
16
+
17
+ try {
18
+ feed = typeof content === "string" ? JSON.parse(content) : content;
19
+ } catch (error) {
20
+ throw new Error(`JSON Feed parse error: ${error.message}`);
21
+ }
22
+
23
+ // Validate JSON Feed structure
24
+ if (!feed.version || !feed.version.includes("jsonfeed.org")) {
25
+ throw new Error("Invalid JSON Feed: missing or invalid version");
26
+ }
27
+
28
+ if (!Array.isArray(feed.items)) {
29
+ throw new TypeError("Invalid JSON Feed: items must be an array");
30
+ }
31
+
32
+ const normalizedMeta = normalizeJsonFeedMeta(feed, feedUrl);
33
+ const normalizedItems = feed.items.map((item) =>
34
+ normalizeJsonFeedItem(item, feedUrl),
35
+ );
36
+
37
+ return {
38
+ type: "feed",
39
+ url: feedUrl,
40
+ ...normalizedMeta,
41
+ items: normalizedItems,
42
+ };
43
+ }
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Feed normalizer - converts all feed formats to jf2
3
+ * @module feeds/normalizer
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+
8
+ import sanitizeHtml from "sanitize-html";
9
+
10
+ /**
11
+ * Sanitize HTML options
12
+ */
13
+ const SANITIZE_OPTIONS = {
14
+ allowedTags: [
15
+ "a",
16
+ "abbr",
17
+ "b",
18
+ "blockquote",
19
+ "br",
20
+ "code",
21
+ "em",
22
+ "figcaption",
23
+ "figure",
24
+ "h1",
25
+ "h2",
26
+ "h3",
27
+ "h4",
28
+ "h5",
29
+ "h6",
30
+ "hr",
31
+ "i",
32
+ "img",
33
+ "li",
34
+ "ol",
35
+ "p",
36
+ "pre",
37
+ "s",
38
+ "span",
39
+ "strike",
40
+ "strong",
41
+ "sub",
42
+ "sup",
43
+ "table",
44
+ "tbody",
45
+ "td",
46
+ "th",
47
+ "thead",
48
+ "tr",
49
+ "u",
50
+ "ul",
51
+ "video",
52
+ "audio",
53
+ "source",
54
+ ],
55
+ allowedAttributes: {
56
+ a: ["href", "title", "rel"],
57
+ img: ["src", "alt", "title", "width", "height"],
58
+ video: ["src", "poster", "controls", "width", "height"],
59
+ audio: ["src", "controls"],
60
+ source: ["src", "type"],
61
+ "*": ["class"],
62
+ },
63
+ allowedSchemes: ["http", "https", "mailto"],
64
+ };
65
+
66
+ /**
67
+ * Generate unique ID for an item
68
+ * @param {string} feedUrl - Feed URL
69
+ * @param {string} itemId - Item identifier (URL or ID)
70
+ * @returns {string} Unique ID hash
71
+ */
72
+ export function generateItemUid(feedUrl, itemId) {
73
+ const hash = crypto.createHash("sha256");
74
+ hash.update(`${feedUrl}::${itemId}`);
75
+ return hash.digest("hex").slice(0, 24);
76
+ }
77
+
78
+ /**
79
+ * Normalize RSS/Atom item from feedparser
80
+ * @param {object} item - Feedparser item
81
+ * @param {string} feedUrl - Feed URL
82
+ * @param {string} feedType - 'rss' or 'atom'
83
+ * @returns {object} Normalized jf2 item
84
+ */
85
+ export function normalizeItem(item, feedUrl, feedType) {
86
+ const url = item.link || item.origlink || item.guid;
87
+ const uid = generateItemUid(feedUrl, item.guid || url || item.title);
88
+
89
+ const normalized = {
90
+ type: "entry",
91
+ uid,
92
+ url,
93
+ name: item.title || undefined,
94
+ published: item.pubdate ? new Date(item.pubdate).toISOString() : undefined,
95
+ updated: item.date ? new Date(item.date).toISOString() : undefined,
96
+ _source: {
97
+ feedUrl,
98
+ feedType,
99
+ originalId: item.guid,
100
+ },
101
+ };
102
+
103
+ // Content
104
+ if (item.description || item.summary) {
105
+ const html = item.description || item.summary;
106
+ normalized.content = {
107
+ html: sanitizeHtml(html, SANITIZE_OPTIONS),
108
+ text: sanitizeHtml(html, { allowedTags: [] }).trim(),
109
+ };
110
+ }
111
+
112
+ // Summary (prefer explicit summary over truncated content)
113
+ if (item.summary && item.description && item.summary !== item.description) {
114
+ normalized.summary = sanitizeHtml(item.summary, { allowedTags: [] }).trim();
115
+ }
116
+
117
+ // Author
118
+ if (item.author || item["dc:creator"]) {
119
+ const authorName = item.author || item["dc:creator"];
120
+ normalized.author = {
121
+ type: "card",
122
+ name: authorName,
123
+ };
124
+ }
125
+
126
+ // Categories/tags
127
+ if (item.categories && item.categories.length > 0) {
128
+ normalized.category = item.categories;
129
+ }
130
+
131
+ // Enclosures (media)
132
+ if (item.enclosures && item.enclosures.length > 0) {
133
+ for (const enclosure of item.enclosures) {
134
+ const mediaUrl = enclosure.url;
135
+ const mediaType = enclosure.type || "";
136
+
137
+ if (mediaType.startsWith("image/")) {
138
+ normalized.photo = normalized.photo || [];
139
+ normalized.photo.push(mediaUrl);
140
+ } else if (mediaType.startsWith("video/")) {
141
+ normalized.video = normalized.video || [];
142
+ normalized.video.push(mediaUrl);
143
+ } else if (mediaType.startsWith("audio/")) {
144
+ normalized.audio = normalized.audio || [];
145
+ normalized.audio.push(mediaUrl);
146
+ }
147
+ }
148
+ }
149
+
150
+ // Featured image from media content
151
+ if (item["media:content"] && item["media:content"].url) {
152
+ const mediaType = item["media:content"].type || "";
153
+ if (
154
+ mediaType.startsWith("image/") ||
155
+ item["media:content"].medium === "image"
156
+ ) {
157
+ normalized.photo = normalized.photo || [];
158
+ if (!normalized.photo.includes(item["media:content"].url)) {
159
+ normalized.photo.push(item["media:content"].url);
160
+ }
161
+ }
162
+ }
163
+
164
+ // Image from item.image
165
+ if (item.image && item.image.url) {
166
+ normalized.photo = normalized.photo || [];
167
+ if (!normalized.photo.includes(item.image.url)) {
168
+ normalized.photo.push(item.image.url);
169
+ }
170
+ }
171
+
172
+ return normalized;
173
+ }
174
+
175
+ /**
176
+ * Normalize feed metadata from feedparser
177
+ * @param {object} meta - Feedparser meta object
178
+ * @param {string} feedUrl - Feed URL
179
+ * @returns {object} Normalized feed metadata
180
+ */
181
+ export function normalizeFeedMeta(meta, feedUrl) {
182
+ const normalized = {
183
+ name: meta.title || feedUrl,
184
+ };
185
+
186
+ if (meta.description) {
187
+ normalized.summary = meta.description;
188
+ }
189
+
190
+ if (meta.link) {
191
+ normalized.url = meta.link;
192
+ }
193
+
194
+ if (meta.image && meta.image.url) {
195
+ normalized.photo = meta.image.url;
196
+ }
197
+
198
+ if (meta.favicon) {
199
+ normalized.photo = normalized.photo || meta.favicon;
200
+ }
201
+
202
+ // Author/publisher
203
+ if (meta.author) {
204
+ normalized.author = {
205
+ type: "card",
206
+ name: meta.author,
207
+ };
208
+ }
209
+
210
+ // Hub for WebSub
211
+ if (meta.cloud && meta.cloud.href) {
212
+ normalized._hub = meta.cloud.href;
213
+ }
214
+
215
+ // Look for hub in links
216
+ if (meta.link && meta["atom:link"]) {
217
+ const links = Array.isArray(meta["atom:link"])
218
+ ? meta["atom:link"]
219
+ : [meta["atom:link"]];
220
+ for (const link of links) {
221
+ if (link["@"] && link["@"].rel === "hub") {
222
+ normalized._hub = link["@"].href;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+
228
+ return normalized;
229
+ }
230
+
231
+ /**
232
+ * Normalize JSON Feed item
233
+ * @param {object} item - JSON Feed item
234
+ * @param {string} feedUrl - Feed URL
235
+ * @returns {object} Normalized jf2 item
236
+ */
237
+ export function normalizeJsonFeedItem(item, feedUrl) {
238
+ const url = item.url || item.external_url;
239
+ const uid = generateItemUid(feedUrl, item.id || url);
240
+
241
+ const normalized = {
242
+ type: "entry",
243
+ uid,
244
+ url,
245
+ name: item.title || undefined,
246
+ published: item.date_published
247
+ ? new Date(item.date_published).toISOString()
248
+ : undefined,
249
+ updated: item.date_modified
250
+ ? new Date(item.date_modified).toISOString()
251
+ : undefined,
252
+ _source: {
253
+ feedUrl,
254
+ feedType: "jsonfeed",
255
+ originalId: item.id,
256
+ },
257
+ };
258
+
259
+ // Content
260
+ if (item.content_html || item.content_text) {
261
+ normalized.content = {};
262
+ if (item.content_html) {
263
+ normalized.content.html = sanitizeHtml(
264
+ item.content_html,
265
+ SANITIZE_OPTIONS,
266
+ );
267
+ normalized.content.text = sanitizeHtml(item.content_html, {
268
+ allowedTags: [],
269
+ }).trim();
270
+ } else if (item.content_text) {
271
+ normalized.content.text = item.content_text;
272
+ }
273
+ }
274
+
275
+ // Summary
276
+ if (item.summary) {
277
+ normalized.summary = item.summary;
278
+ }
279
+
280
+ // Author
281
+ if (item.author || item.authors) {
282
+ const author = item.author || (item.authors && item.authors[0]);
283
+ if (author) {
284
+ normalized.author = {
285
+ type: "card",
286
+ name: author.name,
287
+ url: author.url,
288
+ photo: author.avatar,
289
+ };
290
+ }
291
+ }
292
+
293
+ // Tags
294
+ if (item.tags && item.tags.length > 0) {
295
+ normalized.category = item.tags;
296
+ }
297
+
298
+ // Featured image
299
+ if (item.image) {
300
+ normalized.photo = [item.image];
301
+ }
302
+
303
+ if (item.banner_image && !normalized.photo) {
304
+ normalized.photo = [item.banner_image];
305
+ }
306
+
307
+ // Attachments
308
+ if (item.attachments && item.attachments.length > 0) {
309
+ for (const attachment of item.attachments) {
310
+ const mediaType = attachment.mime_type || "";
311
+
312
+ if (mediaType.startsWith("image/")) {
313
+ normalized.photo = normalized.photo || [];
314
+ normalized.photo.push(attachment.url);
315
+ } else if (mediaType.startsWith("video/")) {
316
+ normalized.video = normalized.video || [];
317
+ normalized.video.push(attachment.url);
318
+ } else if (mediaType.startsWith("audio/")) {
319
+ normalized.audio = normalized.audio || [];
320
+ normalized.audio.push(attachment.url);
321
+ }
322
+ }
323
+ }
324
+
325
+ // External URL
326
+ if (item.external_url && item.url !== item.external_url) {
327
+ normalized["bookmark-of"] = [item.external_url];
328
+ }
329
+
330
+ return normalized;
331
+ }
332
+
333
+ /**
334
+ * Normalize JSON Feed metadata
335
+ * @param {object} feed - JSON Feed object
336
+ * @param {string} feedUrl - Feed URL
337
+ * @returns {object} Normalized feed metadata
338
+ */
339
+ export function normalizeJsonFeedMeta(feed, feedUrl) {
340
+ const normalized = {
341
+ name: feed.title || feedUrl,
342
+ };
343
+
344
+ if (feed.description) {
345
+ normalized.summary = feed.description;
346
+ }
347
+
348
+ if (feed.home_page_url) {
349
+ normalized.url = feed.home_page_url;
350
+ }
351
+
352
+ if (feed.icon) {
353
+ normalized.photo = feed.icon;
354
+ } else if (feed.favicon) {
355
+ normalized.photo = feed.favicon;
356
+ }
357
+
358
+ if (feed.author || feed.authors) {
359
+ const author = feed.author || (feed.authors && feed.authors[0]);
360
+ if (author) {
361
+ normalized.author = {
362
+ type: "card",
363
+ name: author.name,
364
+ url: author.url,
365
+ photo: author.avatar,
366
+ };
367
+ }
368
+ }
369
+
370
+ // Hub for WebSub
371
+ if (feed.hubs && feed.hubs.length > 0) {
372
+ normalized._hub = feed.hubs[0].url;
373
+ }
374
+
375
+ return normalized;
376
+ }
377
+
378
+ /**
379
+ * Normalize h-feed entry
380
+ * @param {object} entry - Microformats h-entry
381
+ * @param {string} feedUrl - Feed URL
382
+ * @returns {object} Normalized jf2 item
383
+ */
384
+ export function normalizeHfeedItem(entry, feedUrl) {
385
+ const properties = entry.properties || {};
386
+ const url = getFirst(properties.url) || getFirst(properties.uid);
387
+ const uid = generateItemUid(feedUrl, getFirst(properties.uid) || url);
388
+
389
+ const normalized = {
390
+ type: "entry",
391
+ uid,
392
+ url,
393
+ _source: {
394
+ feedUrl,
395
+ feedType: "hfeed",
396
+ originalId: getFirst(properties.uid),
397
+ },
398
+ };
399
+
400
+ // Name/title
401
+ if (properties.name) {
402
+ const name = getFirst(properties.name);
403
+ // Only include name if it's not just the content
404
+ if (
405
+ name &&
406
+ (!properties.content || name !== getContentText(properties.content))
407
+ ) {
408
+ normalized.name = name;
409
+ }
410
+ }
411
+
412
+ // Published
413
+ if (properties.published) {
414
+ const published = getFirst(properties.published);
415
+ normalized.published = new Date(published).toISOString();
416
+ }
417
+
418
+ // Updated
419
+ if (properties.updated) {
420
+ const updated = getFirst(properties.updated);
421
+ normalized.updated = new Date(updated).toISOString();
422
+ }
423
+
424
+ // Content
425
+ if (properties.content) {
426
+ const content = getFirst(properties.content);
427
+ if (typeof content === "object") {
428
+ normalized.content = {
429
+ html: content.html
430
+ ? sanitizeHtml(content.html, SANITIZE_OPTIONS)
431
+ : undefined,
432
+ text: content.value || undefined,
433
+ };
434
+ } else if (typeof content === "string") {
435
+ normalized.content = { text: content };
436
+ }
437
+ }
438
+
439
+ // Summary
440
+ if (properties.summary) {
441
+ normalized.summary = getFirst(properties.summary);
442
+ }
443
+
444
+ // Author
445
+ if (properties.author) {
446
+ const author = getFirst(properties.author);
447
+ normalized.author = normalizeHcard(author);
448
+ }
449
+
450
+ // Categories
451
+ if (properties.category) {
452
+ normalized.category = properties.category;
453
+ }
454
+
455
+ // Photos
456
+ if (properties.photo) {
457
+ normalized.photo = properties.photo.map((p) =>
458
+ typeof p === "object" ? p.value || p.url : p,
459
+ );
460
+ }
461
+
462
+ // Videos
463
+ if (properties.video) {
464
+ normalized.video = properties.video.map((v) =>
465
+ typeof v === "object" ? v.value || v.url : v,
466
+ );
467
+ }
468
+
469
+ // Audio
470
+ if (properties.audio) {
471
+ normalized.audio = properties.audio.map((a) =>
472
+ typeof a === "object" ? a.value || a.url : a,
473
+ );
474
+ }
475
+
476
+ // Interaction types
477
+ if (properties["like-of"]) {
478
+ normalized["like-of"] = properties["like-of"];
479
+ }
480
+ if (properties["repost-of"]) {
481
+ normalized["repost-of"] = properties["repost-of"];
482
+ }
483
+ if (properties["bookmark-of"]) {
484
+ normalized["bookmark-of"] = properties["bookmark-of"];
485
+ }
486
+ if (properties["in-reply-to"]) {
487
+ normalized["in-reply-to"] = properties["in-reply-to"];
488
+ }
489
+
490
+ // RSVP
491
+ if (properties.rsvp) {
492
+ normalized.rsvp = getFirst(properties.rsvp);
493
+ }
494
+
495
+ // Syndication
496
+ if (properties.syndication) {
497
+ normalized.syndication = properties.syndication;
498
+ }
499
+
500
+ return normalized;
501
+ }
502
+
503
+ /**
504
+ * Normalize h-feed metadata
505
+ * @param {object} hfeed - h-feed microformat object
506
+ * @param {string} feedUrl - Feed URL
507
+ * @returns {object} Normalized feed metadata
508
+ */
509
+ export function normalizeHfeedMeta(hfeed, feedUrl) {
510
+ const properties = hfeed.properties || {};
511
+
512
+ const normalized = {
513
+ name: getFirst(properties.name) || feedUrl,
514
+ };
515
+
516
+ if (properties.summary) {
517
+ normalized.summary = getFirst(properties.summary);
518
+ }
519
+
520
+ if (properties.url) {
521
+ normalized.url = getFirst(properties.url);
522
+ }
523
+
524
+ if (properties.photo) {
525
+ normalized.photo = getFirst(properties.photo);
526
+ if (typeof normalized.photo === "object") {
527
+ normalized.photo = normalized.photo.value || normalized.photo.url;
528
+ }
529
+ }
530
+
531
+ if (properties.author) {
532
+ const author = getFirst(properties.author);
533
+ normalized.author = normalizeHcard(author);
534
+ }
535
+
536
+ return normalized;
537
+ }
538
+
539
+ /**
540
+ * Normalize h-card author
541
+ * @param {object|string} hcard - h-card or author name string
542
+ * @returns {object} Normalized author object
543
+ */
544
+ function normalizeHcard(hcard) {
545
+ if (typeof hcard === "string") {
546
+ return { type: "card", name: hcard };
547
+ }
548
+
549
+ if (!hcard || !hcard.properties) {
550
+ return;
551
+ }
552
+
553
+ const properties = hcard.properties;
554
+
555
+ return {
556
+ type: "card",
557
+ name: getFirst(properties.name),
558
+ url: getFirst(properties.url),
559
+ photo: getFirst(properties.photo),
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Get first item from array or return the value itself
565
+ * @param {Array|*} value - Value or array of values
566
+ * @returns {*} First value or the value itself
567
+ */
568
+ function getFirst(value) {
569
+ if (Array.isArray(value)) {
570
+ return value[0];
571
+ }
572
+ return value;
573
+ }
574
+
575
+ /**
576
+ * Get text content from content property
577
+ * @param {Array} content - Content property array
578
+ * @returns {string} Text content
579
+ */
580
+ function getContentText(content) {
581
+ const first = getFirst(content);
582
+ if (typeof first === "object") {
583
+ return first.value || first.text || "";
584
+ }
585
+ return first || "";
586
+ }