@rmdes/indiekit-endpoint-microsub 1.0.55 → 1.0.57

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 (75) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +61 -49
  3. package/lib/activitypub/outbox-fetcher.js +14 -2
  4. package/lib/cache/redis.js +26 -7
  5. package/lib/controllers/channels.js +2 -2
  6. package/lib/controllers/reader/actor.js +142 -0
  7. package/lib/controllers/reader/channel.js +301 -0
  8. package/lib/controllers/reader/compose.js +242 -0
  9. package/lib/controllers/reader/deck.js +129 -0
  10. package/lib/controllers/reader/feed-repair.js +117 -0
  11. package/lib/controllers/reader/feed.js +246 -0
  12. package/lib/controllers/reader/index.js +126 -0
  13. package/lib/controllers/reader/search.js +157 -0
  14. package/lib/controllers/reader/timeline.js +250 -0
  15. package/lib/controllers/search.js +6 -0
  16. package/lib/controllers/timeline.js +6 -4
  17. package/lib/feeds/atom.js +1 -1
  18. package/lib/feeds/capabilities.js +5 -0
  19. package/lib/feeds/fetcher.js +5 -28
  20. package/lib/feeds/hfeed.js +1 -1
  21. package/lib/feeds/jsonfeed.js +1 -1
  22. package/lib/feeds/normalizer-hfeed.js +209 -0
  23. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  24. package/lib/feeds/normalizer-rss.js +178 -0
  25. package/lib/feeds/normalizer.js +22 -614
  26. package/lib/feeds/rss.js +1 -1
  27. package/lib/media/proxy.js +82 -27
  28. package/lib/polling/processor.js +30 -21
  29. package/lib/polling/scheduler.js +2 -0
  30. package/lib/realtime/broker.js +6 -1
  31. package/lib/storage/channels.js +53 -42
  32. package/lib/storage/feeds.js +3 -1
  33. package/lib/storage/items-read-state.js +287 -0
  34. package/lib/storage/items-retention.js +174 -0
  35. package/lib/storage/items-search.js +34 -0
  36. package/lib/storage/items.js +113 -610
  37. package/lib/storage/read-state.js +1 -1
  38. package/lib/utils/async-handler.js +7 -0
  39. package/lib/utils/constants.js +7 -0
  40. package/lib/utils/csrf.js +51 -0
  41. package/lib/utils/html.js +25 -0
  42. package/lib/utils/sanitize.js +61 -0
  43. package/lib/utils/source-type.js +28 -0
  44. package/lib/utils/validation.js +8 -2
  45. package/lib/webmention/processor.js +1 -1
  46. package/lib/webmention/verifier.js +10 -21
  47. package/lib/websub/subscriber.js +12 -0
  48. package/locales/de.json +3 -0
  49. package/locales/en.json +2 -0
  50. package/locales/es-419.json +3 -0
  51. package/locales/es.json +3 -0
  52. package/locales/fr.json +3 -0
  53. package/locales/hi.json +3 -0
  54. package/locales/id.json +3 -0
  55. package/locales/it.json +3 -0
  56. package/locales/nl.json +3 -0
  57. package/locales/pl.json +3 -0
  58. package/locales/pt-BR.json +3 -0
  59. package/locales/pt.json +3 -0
  60. package/locales/sr.json +3 -0
  61. package/locales/sv.json +3 -0
  62. package/locales/zh-Hans-CN.json +3 -0
  63. package/package.json +3 -1
  64. package/views/actor.njk +2 -0
  65. package/views/channel-new.njk +1 -0
  66. package/views/channel.njk +3 -344
  67. package/views/compose.njk +1 -0
  68. package/views/deck-settings.njk +1 -0
  69. package/views/feed-edit.njk +3 -0
  70. package/views/feeds.njk +4 -0
  71. package/views/layouts/reader.njk +1 -0
  72. package/views/search.njk +2 -0
  73. package/views/settings.njk +2 -0
  74. package/views/timeline.njk +3 -271
  75. package/lib/controllers/reader.js +0 -1580
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Timeline views + read state
3
+ * @module controllers/reader/timeline
4
+ */
5
+
6
+ import { getChannel, getChannelById, getChannelsWithColors } from "../../storage/channels.js";
7
+ import {
8
+ getTimelineItems,
9
+ getAllTimelineItems,
10
+ getItemById,
11
+ } from "../../storage/items.js";
12
+ import { markItemsRead } from "../../storage/items-read-state.js";
13
+ import { getUserId } from "../../utils/auth.js";
14
+ import { proxyItemImages } from "../../media/proxy.js";
15
+
16
+ /**
17
+ * Timeline view - all channels chronologically
18
+ * @param {object} request - Express request
19
+ * @param {object} response - Express response
20
+ */
21
+ export async function timeline(request, response) {
22
+ const { application } = request.app.locals;
23
+ const userId = getUserId(request);
24
+ const { before, after } = request.query;
25
+
26
+ // Get channels with colors for filtering UI and item decoration
27
+ const channelList = await getChannelsWithColors(application, userId);
28
+
29
+ // Build channel lookup map (ObjectId string -> { name, color, uid })
30
+ const channelMap = new Map();
31
+ for (const ch of channelList) {
32
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
33
+ }
34
+
35
+ // Parse excluded channel IDs from query params
36
+ const excludeParam = request.query.exclude;
37
+ const excludeIds = excludeParam
38
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
39
+ : [];
40
+
41
+ // Exclude the notifications channel by default
42
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
43
+ const excludeChannelIds = [...excludeIds];
44
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
45
+ excludeChannelIds.push(notificationsChannel._id.toString());
46
+ }
47
+
48
+ const result = await getAllTimelineItems(application, {
49
+ before,
50
+ after,
51
+ userId,
52
+ excludeChannelIds,
53
+ });
54
+
55
+ // Proxy images
56
+ const proxyBaseUrl = application.url;
57
+ if (proxyBaseUrl && result.items) {
58
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
59
+ }
60
+
61
+ // Decorate items with channel name and color
62
+ for (const item of result.items) {
63
+ if (item._channelId) {
64
+ const info = channelMap.get(item._channelId);
65
+ if (info) {
66
+ item._channelName = info.name;
67
+ item._channelColor = info.color;
68
+ item._channelUid = info.uid;
69
+ }
70
+ }
71
+ }
72
+
73
+ // Set view preference cookie
74
+ if (request.session) request.session.microsubView = "timeline";
75
+
76
+ response.render("timeline", {
77
+ title: "Timeline",
78
+ channels: channelList,
79
+ items: result.items,
80
+ paging: result.paging,
81
+ excludeIds,
82
+ baseUrl: request.baseUrl,
83
+ readerBaseUrl: request.baseUrl,
84
+ activeView: "timeline",
85
+ breadcrumbs: [
86
+ { text: "Reader", href: request.baseUrl },
87
+ { text: "Timeline" },
88
+ ],
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Return rendered HTML fragments for timeline infinite scroll
94
+ * @param {object} request - Express request
95
+ * @param {object} response - Express response
96
+ * @returns {Promise<void>}
97
+ */
98
+ export async function timelineHtml(request, response) {
99
+ const { application } = request.app.locals;
100
+ const userId = getUserId(request);
101
+ const { before, after } = request.query;
102
+
103
+ const channelList = await getChannelsWithColors(application, userId);
104
+ const channelMap = new Map();
105
+ for (const ch of channelList) {
106
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
107
+ }
108
+
109
+ const excludeParam = request.query.exclude;
110
+ const excludeIds = excludeParam
111
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
112
+ : [];
113
+
114
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
115
+ const excludeChannelIds = [...excludeIds];
116
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
117
+ excludeChannelIds.push(notificationsChannel._id.toString());
118
+ }
119
+
120
+ const result = await getAllTimelineItems(application, {
121
+ before,
122
+ after,
123
+ userId,
124
+ excludeChannelIds,
125
+ });
126
+
127
+ const proxyBaseUrl = application.url;
128
+ if (proxyBaseUrl && result.items) {
129
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
130
+ }
131
+
132
+ for (const item of result.items) {
133
+ if (item._channelId) {
134
+ const info = channelMap.get(item._channelId);
135
+ if (info) {
136
+ item._channelName = info.name;
137
+ item._channelColor = info.color;
138
+ item._channelUid = info.uid;
139
+ }
140
+ }
141
+ }
142
+
143
+ const fragmentHtml = await new Promise((resolve, reject) => {
144
+ response.render("partials/items-fragment-timeline", {
145
+ items: result.items,
146
+ baseUrl: request.baseUrl,
147
+ }, (error, html) => error ? reject(error) : resolve(html));
148
+ });
149
+
150
+ response.json({
151
+ html: fragmentHtml,
152
+ paging: result.paging,
153
+ count: result.items.length,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Mark all items in channel as read
159
+ * @param {object} request - Express request
160
+ * @param {object} response - Express response
161
+ * @returns {Promise<void>}
162
+ */
163
+ export async function markAllRead(request, response) {
164
+ const { application } = request.app.locals;
165
+ const userId = getUserId(request);
166
+ const { channel: channelUid } = request.body;
167
+
168
+ const channelDocument = await getChannel(application, channelUid, userId);
169
+ if (!channelDocument) {
170
+ return response.status(404).render("404");
171
+ }
172
+
173
+ // Mark all items as read using the special "last-read-entry" value
174
+ await markItemsRead(
175
+ application,
176
+ channelDocument._id,
177
+ ["last-read-entry"],
178
+ userId,
179
+ );
180
+
181
+ response.redirect(`${request.baseUrl}/channels/${channelUid}`);
182
+ }
183
+
184
+ /**
185
+ * Mark specific items as read (no-JS form fallback for mark-view-as-read)
186
+ * @param {object} request - Express request
187
+ * @param {object} response - Express response
188
+ */
189
+ export async function markViewRead(request, response) {
190
+ const { application } = request.app.locals;
191
+ const userId = getUserId(request);
192
+ const { channel: channelUid } = request.body;
193
+ let { entry } = request.body;
194
+
195
+ const channelDocument = await getChannel(application, channelUid, userId);
196
+ if (!channelDocument) {
197
+ return response.status(404).render("404");
198
+ }
199
+
200
+ const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
201
+ if (entryIds.length > 0) {
202
+ await markItemsRead(application, channelDocument._id, entryIds, userId);
203
+ }
204
+
205
+ response.redirect(`${request.baseUrl}/channels/${channelUid}`);
206
+ }
207
+
208
+ /**
209
+ * View single item
210
+ * @param {object} request - Express request
211
+ * @param {object} response - Express response
212
+ * @returns {Promise<void>}
213
+ */
214
+ export async function item(request, response) {
215
+ const { application } = request.app.locals;
216
+ const userId = getUserId(request);
217
+ const { id } = request.params;
218
+
219
+ const itemDocument = await getItemById(application, id, userId);
220
+ if (!itemDocument) {
221
+ return response.status(404).render("404");
222
+ }
223
+
224
+ // Get the channel for this item (needed for mark-read)
225
+ let channel = null;
226
+ if (itemDocument.channelId) {
227
+ channel = await getChannelById(application, itemDocument.channelId);
228
+ }
229
+
230
+ const itemBreadcrumbs = [
231
+ { text: "Reader", href: request.baseUrl },
232
+ ];
233
+ if (channel) {
234
+ itemBreadcrumbs.push(
235
+ { text: "Channels", href: `${request.baseUrl}/channels` },
236
+ { text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
237
+ );
238
+ }
239
+ itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
240
+
241
+ response.render("item", {
242
+ title: itemDocument.name || "Item",
243
+ item: itemDocument,
244
+ channel,
245
+ baseUrl: request.baseUrl,
246
+ readerBaseUrl: request.baseUrl,
247
+ activeView: "channels",
248
+ breadcrumbs: itemBreadcrumbs,
249
+ });
250
+ }
@@ -6,6 +6,7 @@
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
8
  import { discoverFeeds } from "../feeds/hfeed.js";
9
+ import { isPrivateUrl } from "../media/proxy.js";
9
10
  import { searchWithFallback } from "../search/query.js";
10
11
  import { getChannel } from "../storage/channels.js";
11
12
  import { getUserId } from "../utils/auth.js";
@@ -35,6 +36,11 @@ export async function discover(request, response) {
35
36
  return response.json({ results: [] });
36
37
  }
37
38
 
39
+ // SSRF protection
40
+ if (await isPrivateUrl(url.href)) {
41
+ throw new IndiekitError("URL blocked (private/internal address)", { status: 400 });
42
+ }
43
+
38
44
  try {
39
45
  // Fetch the URL content
40
46
  const fetchResponse = await fetch(url.href, {
@@ -9,16 +9,18 @@ import { proxyItemImages } from "../media/proxy.js";
9
9
  import { getChannel, getChannelById } from "../storage/channels.js";
10
10
  import {
11
11
  getTimelineItems,
12
+ removeItems,
13
+ } from "../storage/items.js";
14
+ import {
12
15
  markFeedItemsRead,
13
16
  markItemsRead,
14
17
  markItemsUnread,
15
- removeItems,
16
- } from "../storage/items.js";
18
+ } from "../storage/items-read-state.js";
17
19
  import { getUserId } from "../utils/auth.js";
18
20
  import {
19
21
  validateChannel,
20
22
  validateEntries,
21
- parseArrayParameter as parseArrayParametereter,
23
+ parseArrayParameter,
22
24
  } from "../utils/validation.js";
23
25
 
24
26
  /**
@@ -90,7 +92,7 @@ export async function action(request, response) {
90
92
  }
91
93
 
92
94
  // Get entry IDs from request
93
- const entries = parseArrayParametereter(request.body, "entry");
95
+ const entries = parseArrayParameter(request.body, "entry");
94
96
 
95
97
  switch (method) {
96
98
  case "mark_read": {
package/lib/feeds/atom.js CHANGED
@@ -7,7 +7,7 @@ import { Readable } from "node:stream";
7
7
 
8
8
  import FeedParser from "feedparser";
9
9
 
10
- import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
10
+ import { normalizeItem, normalizeFeedMeta } from "./normalizer-rss.js";
11
11
 
12
12
  /**
13
13
  * Parse Atom feed content
@@ -4,6 +4,8 @@
4
4
  * @module feeds/capabilities
5
5
  */
6
6
 
7
+ import { isPrivateUrl } from "../media/proxy.js";
8
+
7
9
  /**
8
10
  * Known Fediverse domain patterns
9
11
  */
@@ -136,6 +138,9 @@ async function discoverEndpoints(url) {
136
138
  micropub: null,
137
139
  };
138
140
 
141
+ // SSRF protection
142
+ if (await isPrivateUrl(url)) return endpoints;
143
+
139
144
  const controller = new AbortController();
140
145
  const timeout = setTimeout(() => controller.abort(), 15_000);
141
146
 
@@ -3,7 +3,7 @@
3
3
  * @module feeds/fetcher
4
4
  */
5
5
 
6
- import { getCache, setCache } from "../cache/redis.js";
6
+ import { isPrivateUrl } from "../media/proxy.js";
7
7
 
8
8
  const DEFAULT_TIMEOUT = 30_000; // 30 seconds
9
9
  const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
@@ -15,25 +15,14 @@ const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
15
15
  * @param {string} [options.etag] - Previous ETag for conditional request
16
16
  * @param {string} [options.lastModified] - Previous Last-Modified for conditional request
17
17
  * @param {number} [options.timeout] - Request timeout in ms
18
- * @param {object} [options.redis] - Redis client for caching
19
18
  * @returns {Promise<object>} Fetch result with content and headers
20
19
  */
21
20
  export async function fetchFeed(url, options = {}) {
22
- const { etag, lastModified, timeout = DEFAULT_TIMEOUT, redis } = options;
21
+ const { etag, lastModified, timeout = DEFAULT_TIMEOUT } = options;
23
22
 
24
- // Check cache first
25
- if (redis) {
26
- const cached = await getCache(redis, `feed:${url}`);
27
- if (cached) {
28
- return {
29
- content: cached.content,
30
- contentType: cached.contentType,
31
- etag: cached.etag,
32
- lastModified: cached.lastModified,
33
- fromCache: true,
34
- status: 200,
35
- };
36
- }
23
+ // SSRF protection — block private/internal IPs (including DNS rebinding)
24
+ if (await isPrivateUrl(url)) {
25
+ throw new Error(`Feed URL blocked (private/internal address): ${url}`);
37
26
  }
38
27
 
39
28
  const headers = {
@@ -99,18 +88,6 @@ export async function fetchFeed(url, options = {}) {
99
88
  result.self = extractSelfFromLinkHeader(linkHeader);
100
89
  }
101
90
 
102
- // Cache the result
103
- if (redis) {
104
- const cacheData = {
105
- content,
106
- contentType,
107
- etag: responseEtag,
108
- lastModified: responseLastModified,
109
- };
110
- // Cache for 5 minutes by default
111
- await setCache(redis, `feed:${url}`, cacheData, 300);
112
- }
113
-
114
91
  return result;
115
92
  } catch (error) {
116
93
  clearTimeout(timeoutId);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { mf2 } from "microformats-parser";
7
7
 
8
- import { normalizeHfeedItem, normalizeHfeedMeta } from "./normalizer.js";
8
+ import { normalizeHfeedItem, normalizeHfeedMeta } from "./normalizer-hfeed.js";
9
9
 
10
10
  /**
11
11
  * Parse h-feed content from HTML
@@ -3,7 +3,7 @@
3
3
  * @module feeds/jsonfeed
4
4
  */
5
5
 
6
- import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer.js";
6
+ import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer-jsonfeed.js";
7
7
 
8
8
  /**
9
9
  * Parse JSON Feed content
@@ -0,0 +1,209 @@
1
+ /**
2
+ * h-feed (Microformats2) normalization
3
+ * @module feeds/normalizer-hfeed
4
+ */
5
+
6
+ import {
7
+ generateItemUid,
8
+ extractImagesFromHtml,
9
+ extractPhotoUrl,
10
+ normalizeUrlArray,
11
+ getFirst,
12
+ getContentText,
13
+ sanitizeHtml,
14
+ SANITIZE_OPTIONS,
15
+ } from "./normalizer.js";
16
+
17
+ /**
18
+ * Normalize h-card author
19
+ * @param {object|string} hcard - h-card or author name string
20
+ * @returns {object} Normalized author object
21
+ */
22
+ function normalizeHcard(hcard) {
23
+ if (typeof hcard === "string") {
24
+ return { type: "card", name: hcard };
25
+ }
26
+
27
+ if (!hcard || !hcard.properties) {
28
+ return;
29
+ }
30
+
31
+ const properties = hcard.properties;
32
+
33
+ return {
34
+ type: "card",
35
+ name: getFirst(properties.name),
36
+ url: getFirst(properties.url),
37
+ photo: extractPhotoUrl(getFirst(properties.photo)),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Normalize h-feed entry
43
+ * @param {object} entry - Microformats h-entry
44
+ * @param {string} feedUrl - Feed URL
45
+ * @returns {object} Normalized jf2 item
46
+ */
47
+ export function normalizeHfeedItem(entry, feedUrl) {
48
+ const properties = entry.properties || {};
49
+ const url = getFirst(properties.url) || getFirst(properties.uid);
50
+ const uid = generateItemUid(feedUrl, getFirst(properties.uid) || url);
51
+
52
+ const normalized = {
53
+ type: "entry",
54
+ uid,
55
+ url,
56
+ _source: {
57
+ url: feedUrl,
58
+ feedUrl,
59
+ feedType: "hfeed",
60
+ originalId: getFirst(properties.uid),
61
+ },
62
+ };
63
+
64
+ // Name/title
65
+ if (properties.name) {
66
+ const name = getFirst(properties.name);
67
+ // Only include name if it's not just the content
68
+ if (
69
+ name &&
70
+ (!properties.content || name !== getContentText(properties.content))
71
+ ) {
72
+ normalized.name = name;
73
+ }
74
+ }
75
+
76
+ // Published
77
+ if (properties.published) {
78
+ const published = getFirst(properties.published);
79
+ normalized.published = new Date(published).toISOString();
80
+ }
81
+
82
+ // Updated
83
+ if (properties.updated) {
84
+ const updated = getFirst(properties.updated);
85
+ normalized.updated = new Date(updated).toISOString();
86
+ }
87
+
88
+ // Content
89
+ if (properties.content) {
90
+ const content = getFirst(properties.content);
91
+ if (typeof content === "object") {
92
+ normalized.content = {
93
+ html: content.html
94
+ ? sanitizeHtml(content.html, SANITIZE_OPTIONS)
95
+ : undefined,
96
+ text: content.value || undefined,
97
+ };
98
+ } else if (typeof content === "string") {
99
+ normalized.content = { text: content };
100
+ }
101
+ }
102
+
103
+ // Summary
104
+ if (properties.summary) {
105
+ normalized.summary = getFirst(properties.summary);
106
+ }
107
+
108
+ // Author
109
+ if (properties.author) {
110
+ const author = getFirst(properties.author);
111
+ normalized.author = normalizeHcard(author);
112
+ }
113
+
114
+ // Categories
115
+ if (properties.category) {
116
+ normalized.category = properties.category;
117
+ }
118
+
119
+ // Photos
120
+ if (properties.photo) {
121
+ normalized.photo = properties.photo.map((p) =>
122
+ typeof p === "object" ? p.value || p.url : p,
123
+ );
124
+ }
125
+
126
+ // Videos
127
+ if (properties.video) {
128
+ normalized.video = properties.video.map((v) =>
129
+ typeof v === "object" ? v.value || v.url : v,
130
+ );
131
+ }
132
+
133
+ // Audio
134
+ if (properties.audio) {
135
+ normalized.audio = properties.audio.map((a) =>
136
+ typeof a === "object" ? a.value || a.url : a,
137
+ );
138
+ }
139
+
140
+ // Interaction types - normalize to string URLs
141
+ if (properties["like-of"]) {
142
+ normalized["like-of"] = normalizeUrlArray(properties["like-of"]);
143
+ }
144
+ if (properties["repost-of"]) {
145
+ normalized["repost-of"] = normalizeUrlArray(properties["repost-of"]);
146
+ }
147
+ if (properties["bookmark-of"]) {
148
+ normalized["bookmark-of"] = normalizeUrlArray(properties["bookmark-of"]);
149
+ }
150
+ if (properties["in-reply-to"]) {
151
+ normalized["in-reply-to"] = normalizeUrlArray(properties["in-reply-to"]);
152
+ }
153
+
154
+ // RSVP
155
+ if (properties.rsvp) {
156
+ normalized.rsvp = getFirst(properties.rsvp);
157
+ }
158
+
159
+ // Syndication
160
+ if (properties.syndication) {
161
+ normalized.syndication = properties.syndication;
162
+ }
163
+
164
+ // Extract images from HTML content as fallback
165
+ if (!normalized.photo && normalized.content?.html) {
166
+ const extracted = extractImagesFromHtml(normalized.content.html);
167
+ if (extracted.length > 0) {
168
+ normalized.photo = extracted;
169
+ }
170
+ }
171
+
172
+ return normalized;
173
+ }
174
+
175
+ /**
176
+ * Normalize h-feed metadata
177
+ * @param {object} hfeed - h-feed microformat object
178
+ * @param {string} feedUrl - Feed URL
179
+ * @returns {object} Normalized feed metadata
180
+ */
181
+ export function normalizeHfeedMeta(hfeed, feedUrl) {
182
+ const properties = hfeed.properties || {};
183
+
184
+ const normalized = {
185
+ name: getFirst(properties.name) || feedUrl,
186
+ };
187
+
188
+ if (properties.summary) {
189
+ normalized.summary = getFirst(properties.summary);
190
+ }
191
+
192
+ if (properties.url) {
193
+ normalized.url = getFirst(properties.url);
194
+ }
195
+
196
+ if (properties.photo) {
197
+ normalized.photo = getFirst(properties.photo);
198
+ if (typeof normalized.photo === "object") {
199
+ normalized.photo = normalized.photo.value || normalized.photo.url;
200
+ }
201
+ }
202
+
203
+ if (properties.author) {
204
+ const author = getFirst(properties.author);
205
+ normalized.author = normalizeHcard(author);
206
+ }
207
+
208
+ return normalized;
209
+ }