@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.
- package/assets/reader.js +408 -0
- package/index.js +61 -49
- package/lib/activitypub/outbox-fetcher.js +14 -2
- package/lib/cache/redis.js +26 -7
- package/lib/controllers/channels.js +2 -2
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/search.js +6 -0
- package/lib/controllers/timeline.js +6 -4
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/capabilities.js +5 -0
- package/lib/feeds/fetcher.js +5 -28
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +22 -614
- package/lib/feeds/rss.js +1 -1
- package/lib/media/proxy.js +82 -27
- package/lib/polling/processor.js +30 -21
- package/lib/polling/scheduler.js +2 -0
- package/lib/realtime/broker.js +6 -1
- package/lib/storage/channels.js +53 -42
- package/lib/storage/feeds.js +3 -1
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +113 -610
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/constants.js +7 -0
- package/lib/utils/csrf.js +51 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/sanitize.js +61 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/utils/validation.js +8 -2
- package/lib/webmention/processor.js +1 -1
- package/lib/webmention/verifier.js +10 -21
- package/lib/websub/subscriber.js +12 -0
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +3 -1
- package/views/actor.njk +2 -0
- package/views/channel-new.njk +1 -0
- package/views/channel.njk +3 -344
- package/views/compose.njk +1 -0
- package/views/deck-settings.njk +1 -0
- package/views/feed-edit.njk +3 -0
- package/views/feeds.njk +4 -0
- package/views/layouts/reader.njk +1 -0
- package/views/search.njk +2 -0
- package/views/settings.njk +2 -0
- package/views/timeline.njk +3 -271
- 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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
package/lib/feeds/fetcher.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @module feeds/fetcher
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
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
|
|
21
|
+
const { etag, lastModified, timeout = DEFAULT_TIMEOUT } = options;
|
|
23
22
|
|
|
24
|
-
//
|
|
25
|
-
if (
|
|
26
|
-
|
|
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);
|
package/lib/feeds/hfeed.js
CHANGED
package/lib/feeds/jsonfeed.js
CHANGED
|
@@ -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
|
+
}
|