@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.
- package/README.md +111 -0
- package/index.js +140 -0
- package/lib/cache/redis.js +133 -0
- package/lib/controllers/block.js +85 -0
- package/lib/controllers/channels.js +135 -0
- package/lib/controllers/events.js +56 -0
- package/lib/controllers/follow.js +108 -0
- package/lib/controllers/microsub.js +138 -0
- package/lib/controllers/mute.js +124 -0
- package/lib/controllers/preview.js +67 -0
- package/lib/controllers/reader.js +218 -0
- package/lib/controllers/search.js +142 -0
- package/lib/controllers/timeline.js +117 -0
- package/lib/feeds/atom.js +61 -0
- package/lib/feeds/fetcher.js +205 -0
- package/lib/feeds/hfeed.js +177 -0
- package/lib/feeds/jsonfeed.js +43 -0
- package/lib/feeds/normalizer.js +586 -0
- package/lib/feeds/parser.js +124 -0
- package/lib/feeds/rss.js +61 -0
- package/lib/polling/processor.js +201 -0
- package/lib/polling/scheduler.js +128 -0
- package/lib/polling/tier.js +139 -0
- package/lib/realtime/broker.js +241 -0
- package/lib/search/indexer.js +90 -0
- package/lib/search/query.js +197 -0
- package/lib/storage/channels.js +281 -0
- package/lib/storage/feeds.js +286 -0
- package/lib/storage/filters.js +265 -0
- package/lib/storage/items.js +419 -0
- package/lib/storage/read-state.js +109 -0
- package/lib/utils/jf2.js +170 -0
- package/lib/utils/pagination.js +157 -0
- package/lib/utils/validation.js +217 -0
- package/lib/webmention/processor.js +214 -0
- package/lib/webmention/receiver.js +54 -0
- package/lib/webmention/verifier.js +308 -0
- package/lib/websub/discovery.js +129 -0
- package/lib/websub/handler.js +163 -0
- package/lib/websub/subscriber.js +181 -0
- package/locales/en.json +80 -0
- package/package.json +54 -0
- package/views/channel-new.njk +33 -0
- package/views/channel.njk +41 -0
- package/views/compose.njk +61 -0
- package/views/item.njk +85 -0
- package/views/partials/actions.njk +15 -0
- package/views/partials/author.njk +17 -0
- package/views/partials/item-card.njk +65 -0
- package/views/partials/timeline.njk +10 -0
- package/views/reader.njk +37 -0
- package/views/settings.njk +81 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webmention processor
|
|
3
|
+
* @module webmention/processor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
|
+
import { ensureNotificationsChannel } from "../storage/channels.js";
|
|
8
|
+
|
|
9
|
+
import { verifyWebmention } from "./verifier.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get notifications collection
|
|
13
|
+
* @param {object} application - Indiekit application
|
|
14
|
+
* @returns {object} MongoDB collection
|
|
15
|
+
*/
|
|
16
|
+
function getCollection(application) {
|
|
17
|
+
return application.collections.get("microsub_notifications");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process a webmention
|
|
22
|
+
* @param {object} application - Indiekit application
|
|
23
|
+
* @param {string} source - Source URL
|
|
24
|
+
* @param {string} target - Target URL
|
|
25
|
+
* @param {string} [userId] - User ID (for user-specific notifications)
|
|
26
|
+
* @returns {Promise<object>} Processing result
|
|
27
|
+
*/
|
|
28
|
+
export async function processWebmention(application, source, target, userId) {
|
|
29
|
+
// Verify the webmention
|
|
30
|
+
const verification = await verifyWebmention(source, target);
|
|
31
|
+
|
|
32
|
+
if (!verification.verified) {
|
|
33
|
+
console.log(
|
|
34
|
+
`[Microsub] Webmention verification failed: ${verification.error}`,
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
error: verification.error,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ensure notifications channel exists
|
|
43
|
+
const channel = await ensureNotificationsChannel(application, userId);
|
|
44
|
+
|
|
45
|
+
// Check for existing notification (update if exists)
|
|
46
|
+
const collection = getCollection(application);
|
|
47
|
+
const existing = await collection.findOne({
|
|
48
|
+
source,
|
|
49
|
+
target,
|
|
50
|
+
...(userId && { userId }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const notification = {
|
|
54
|
+
source,
|
|
55
|
+
target,
|
|
56
|
+
userId,
|
|
57
|
+
channelId: channel._id,
|
|
58
|
+
type: verification.type,
|
|
59
|
+
author: verification.author,
|
|
60
|
+
content: verification.content,
|
|
61
|
+
url: verification.url,
|
|
62
|
+
published: verification.published
|
|
63
|
+
? new Date(verification.published)
|
|
64
|
+
: new Date(),
|
|
65
|
+
verified: true,
|
|
66
|
+
readBy: [],
|
|
67
|
+
updatedAt: new Date(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (existing) {
|
|
71
|
+
// Update existing notification
|
|
72
|
+
await collection.updateOne({ _id: existing._id }, { $set: notification });
|
|
73
|
+
notification._id = existing._id;
|
|
74
|
+
} else {
|
|
75
|
+
// Insert new notification
|
|
76
|
+
notification.createdAt = new Date();
|
|
77
|
+
await collection.insertOne(notification);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Publish real-time event
|
|
81
|
+
const redis = getRedisClient(application);
|
|
82
|
+
if (redis && userId) {
|
|
83
|
+
await publishEvent(redis, `microsub:user:${userId}`, {
|
|
84
|
+
type: "new-notification",
|
|
85
|
+
channelId: channel._id.toString(),
|
|
86
|
+
notification: transformNotification(notification),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(
|
|
91
|
+
`[Microsub] Webmention processed: ${verification.type} from ${source}`,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
success: true,
|
|
96
|
+
type: verification.type,
|
|
97
|
+
id: notification._id?.toString(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Delete a webmention (when source no longer links to target)
|
|
103
|
+
* @param {object} application - Indiekit application
|
|
104
|
+
* @param {string} source - Source URL
|
|
105
|
+
* @param {string} target - Target URL
|
|
106
|
+
* @returns {Promise<boolean>} Whether deletion was successful
|
|
107
|
+
*/
|
|
108
|
+
export async function deleteWebmention(application, source, target) {
|
|
109
|
+
const collection = getCollection(application);
|
|
110
|
+
const result = await collection.deleteOne({ source, target });
|
|
111
|
+
return result.deletedCount > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get notifications for a user
|
|
116
|
+
* @param {object} application - Indiekit application
|
|
117
|
+
* @param {string} userId - User ID
|
|
118
|
+
* @param {object} options - Query options
|
|
119
|
+
* @returns {Promise<Array>} Array of notifications
|
|
120
|
+
*/
|
|
121
|
+
export async function getNotifications(application, userId, options = {}) {
|
|
122
|
+
const collection = getCollection(application);
|
|
123
|
+
const { limit = 20, unreadOnly = false } = options;
|
|
124
|
+
|
|
125
|
+
const query = { userId };
|
|
126
|
+
if (unreadOnly) {
|
|
127
|
+
query.readBy = { $ne: userId };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* eslint-disable unicorn/no-array-callback-reference -- query is MongoDB query object */
|
|
131
|
+
const notifications = await collection
|
|
132
|
+
.find(query)
|
|
133
|
+
.toSorted({ published: -1 })
|
|
134
|
+
.limit(limit)
|
|
135
|
+
.toArray();
|
|
136
|
+
/* eslint-enable unicorn/no-array-callback-reference */
|
|
137
|
+
|
|
138
|
+
return notifications.map((n) => transformNotification(n, userId));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Mark notifications as read
|
|
143
|
+
* @param {object} application - Indiekit application
|
|
144
|
+
* @param {string} userId - User ID
|
|
145
|
+
* @param {Array} ids - Notification IDs to mark as read
|
|
146
|
+
* @returns {Promise<number>} Number of notifications updated
|
|
147
|
+
*/
|
|
148
|
+
export async function markNotificationsRead(application, userId, ids) {
|
|
149
|
+
const collection = getCollection(application);
|
|
150
|
+
const { ObjectId } = await import("mongodb");
|
|
151
|
+
|
|
152
|
+
const objectIds = ids.map((id) => {
|
|
153
|
+
try {
|
|
154
|
+
return new ObjectId(id);
|
|
155
|
+
} catch {
|
|
156
|
+
return id;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await collection.updateMany(
|
|
161
|
+
{ _id: { $in: objectIds } },
|
|
162
|
+
{ $addToSet: { readBy: userId } },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return result.modifiedCount;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get unread notification count
|
|
170
|
+
* @param {object} application - Indiekit application
|
|
171
|
+
* @param {string} userId - User ID
|
|
172
|
+
* @returns {Promise<number>} Unread count
|
|
173
|
+
*/
|
|
174
|
+
export async function getUnreadNotificationCount(application, userId) {
|
|
175
|
+
const collection = getCollection(application);
|
|
176
|
+
return collection.countDocuments({
|
|
177
|
+
userId,
|
|
178
|
+
readBy: { $ne: userId },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Transform notification to API format
|
|
184
|
+
* @param {object} notification - Database notification
|
|
185
|
+
* @param {string} [userId] - User ID for read state
|
|
186
|
+
* @returns {object} Transformed notification
|
|
187
|
+
*/
|
|
188
|
+
function transformNotification(notification, userId) {
|
|
189
|
+
return {
|
|
190
|
+
type: "entry",
|
|
191
|
+
uid: notification._id?.toString(),
|
|
192
|
+
url: notification.url || notification.source,
|
|
193
|
+
published: notification.published?.toISOString(),
|
|
194
|
+
author: notification.author,
|
|
195
|
+
content: notification.content,
|
|
196
|
+
_source: notification.source,
|
|
197
|
+
_target: notification.target,
|
|
198
|
+
_type: notification.type, // like, reply, repost, bookmark, mention
|
|
199
|
+
_is_read: userId ? notification.readBy?.includes(userId) : false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create indexes for notifications
|
|
205
|
+
* @param {object} application - Indiekit application
|
|
206
|
+
* @returns {Promise<void>}
|
|
207
|
+
*/
|
|
208
|
+
export async function createNotificationIndexes(application) {
|
|
209
|
+
const collection = getCollection(application);
|
|
210
|
+
|
|
211
|
+
await collection.createIndex({ userId: 1, published: -1 });
|
|
212
|
+
await collection.createIndex({ source: 1, target: 1 });
|
|
213
|
+
await collection.createIndex({ userId: 1, readBy: 1 });
|
|
214
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webmention receiver
|
|
3
|
+
* @module webmention/receiver
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { processWebmention } from "./processor.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Receive a webmention
|
|
10
|
+
* POST /microsub/webmention
|
|
11
|
+
* @param {object} request - Express request
|
|
12
|
+
* @param {object} response - Express response
|
|
13
|
+
*/
|
|
14
|
+
export async function receive(request, response) {
|
|
15
|
+
const { source, target } = request.body;
|
|
16
|
+
|
|
17
|
+
if (!source || !target) {
|
|
18
|
+
return response.status(400).json({
|
|
19
|
+
error: "invalid_request",
|
|
20
|
+
error_description: "Missing source or target parameter",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Validate URLs
|
|
25
|
+
try {
|
|
26
|
+
new URL(source);
|
|
27
|
+
new URL(target);
|
|
28
|
+
} catch {
|
|
29
|
+
return response.status(400).json({
|
|
30
|
+
error: "invalid_request",
|
|
31
|
+
error_description: "Invalid source or target URL",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { application } = request.app.locals;
|
|
36
|
+
const userId = request.session?.userId;
|
|
37
|
+
|
|
38
|
+
// Return 202 Accepted immediately (processing asynchronously)
|
|
39
|
+
response.status(202).json({
|
|
40
|
+
status: "accepted",
|
|
41
|
+
message: "Webmention queued for processing",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Process webmention in background
|
|
45
|
+
setImmediate(async () => {
|
|
46
|
+
try {
|
|
47
|
+
await processWebmention(application, source, target, userId);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error(`[Microsub] Error processing webmention: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const webmentionReceiver = { receive };
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webmention verification
|
|
3
|
+
* @module webmention/verifier
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mf2 } from "microformats-parser";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Verify a webmention
|
|
10
|
+
* @param {string} source - Source URL
|
|
11
|
+
* @param {string} target - Target URL
|
|
12
|
+
* @returns {Promise<object>} Verification result
|
|
13
|
+
*/
|
|
14
|
+
export async function verifyWebmention(source, target) {
|
|
15
|
+
try {
|
|
16
|
+
// Fetch the source URL
|
|
17
|
+
const response = await fetch(source, {
|
|
18
|
+
headers: {
|
|
19
|
+
Accept: "text/html, application/xhtml+xml",
|
|
20
|
+
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
|
21
|
+
},
|
|
22
|
+
redirect: "follow",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
return {
|
|
27
|
+
verified: false,
|
|
28
|
+
error: `Source returned ${response.status}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = await response.text();
|
|
33
|
+
const finalUrl = response.url;
|
|
34
|
+
|
|
35
|
+
// Check if source links to target
|
|
36
|
+
if (!containsLink(content, target)) {
|
|
37
|
+
return {
|
|
38
|
+
verified: false,
|
|
39
|
+
error: "Source does not link to target",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse microformats
|
|
44
|
+
const parsed = mf2(content, { baseUrl: finalUrl });
|
|
45
|
+
const entry = findEntry(parsed, target);
|
|
46
|
+
|
|
47
|
+
if (!entry) {
|
|
48
|
+
// Still valid, just no h-entry context
|
|
49
|
+
return {
|
|
50
|
+
verified: true,
|
|
51
|
+
type: "mention",
|
|
52
|
+
author: undefined,
|
|
53
|
+
content: undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Determine webmention type
|
|
58
|
+
const mentionType = detectMentionType(entry, target);
|
|
59
|
+
|
|
60
|
+
// Extract author
|
|
61
|
+
const author = extractAuthor(entry, parsed);
|
|
62
|
+
|
|
63
|
+
// Extract content
|
|
64
|
+
const webmentionContent = extractContent(entry);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
verified: true,
|
|
68
|
+
type: mentionType,
|
|
69
|
+
author,
|
|
70
|
+
content: webmentionContent,
|
|
71
|
+
url: getFirst(entry.properties.url) || source,
|
|
72
|
+
published: getFirst(entry.properties.published),
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
verified: false,
|
|
77
|
+
error: `Verification failed: ${error.message}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if content contains a link to target
|
|
84
|
+
* @param {string} content - HTML content
|
|
85
|
+
* @param {string} target - Target URL to find
|
|
86
|
+
* @returns {boolean} Whether the link exists
|
|
87
|
+
*/
|
|
88
|
+
function containsLink(content, target) {
|
|
89
|
+
// Normalize target URL for matching
|
|
90
|
+
const normalizedTarget = target.replace(/\/$/, "");
|
|
91
|
+
|
|
92
|
+
// Check for href attribute containing target
|
|
93
|
+
const hrefPattern = new RegExp(
|
|
94
|
+
`href=["']${escapeRegex(normalizedTarget)}/?["']`,
|
|
95
|
+
"i",
|
|
96
|
+
);
|
|
97
|
+
if (hrefPattern.test(content)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Also check without quotes (some edge cases)
|
|
102
|
+
return content.includes(target) || content.includes(normalizedTarget);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find the h-entry that references the target
|
|
107
|
+
* @param {object} parsed - Parsed microformats
|
|
108
|
+
* @param {string} target - Target URL
|
|
109
|
+
* @returns {object|undefined} The h-entry or undefined
|
|
110
|
+
*/
|
|
111
|
+
function findEntry(parsed, target) {
|
|
112
|
+
const normalizedTarget = target.replace(/\/$/, "");
|
|
113
|
+
|
|
114
|
+
for (const item of parsed.items) {
|
|
115
|
+
// Check if this entry references the target
|
|
116
|
+
if (
|
|
117
|
+
item.type?.includes("h-entry") &&
|
|
118
|
+
entryReferencesTarget(item, normalizedTarget)
|
|
119
|
+
) {
|
|
120
|
+
return item;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check children
|
|
124
|
+
if (item.children) {
|
|
125
|
+
for (const child of item.children) {
|
|
126
|
+
if (
|
|
127
|
+
child.type?.includes("h-entry") &&
|
|
128
|
+
entryReferencesTarget(child, normalizedTarget)
|
|
129
|
+
) {
|
|
130
|
+
return child;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Return first h-entry as fallback
|
|
137
|
+
for (const item of parsed.items) {
|
|
138
|
+
if (item.type?.includes("h-entry")) {
|
|
139
|
+
return item;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if an entry references the target URL
|
|
148
|
+
* @param {object} entry - h-entry object
|
|
149
|
+
* @param {string} target - Normalized target URL
|
|
150
|
+
* @returns {boolean} Whether the entry references the target
|
|
151
|
+
*/
|
|
152
|
+
function entryReferencesTarget(entry, target) {
|
|
153
|
+
const properties = entry.properties || {};
|
|
154
|
+
|
|
155
|
+
// Check interaction properties
|
|
156
|
+
const interactionProperties = [
|
|
157
|
+
"in-reply-to",
|
|
158
|
+
"like-of",
|
|
159
|
+
"repost-of",
|
|
160
|
+
"bookmark-of",
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
for (const property of interactionProperties) {
|
|
164
|
+
const values = properties[property] || [];
|
|
165
|
+
for (const value of values) {
|
|
166
|
+
const url =
|
|
167
|
+
typeof value === "string" ? value : value?.properties?.url?.[0];
|
|
168
|
+
if (url && normalizeUrl(url) === target) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Detect the type of webmention
|
|
179
|
+
* @param {object} entry - h-entry object
|
|
180
|
+
* @param {string} target - Target URL
|
|
181
|
+
* @returns {string} Mention type
|
|
182
|
+
*/
|
|
183
|
+
function detectMentionType(entry, target) {
|
|
184
|
+
const properties = entry.properties || {};
|
|
185
|
+
const normalizedTarget = target.replace(/\/$/, "");
|
|
186
|
+
|
|
187
|
+
// Check for specific interaction types
|
|
188
|
+
if (matchesTarget(properties["like-of"], normalizedTarget)) {
|
|
189
|
+
return "like";
|
|
190
|
+
}
|
|
191
|
+
if (matchesTarget(properties["repost-of"], normalizedTarget)) {
|
|
192
|
+
return "repost";
|
|
193
|
+
}
|
|
194
|
+
if (matchesTarget(properties["bookmark-of"], normalizedTarget)) {
|
|
195
|
+
return "bookmark";
|
|
196
|
+
}
|
|
197
|
+
if (matchesTarget(properties["in-reply-to"], normalizedTarget)) {
|
|
198
|
+
return "reply";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return "mention";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if any value in array matches target
|
|
206
|
+
* @param {Array} values - Array of values
|
|
207
|
+
* @param {string} target - Target URL to match
|
|
208
|
+
* @returns {boolean} Whether any value matches
|
|
209
|
+
*/
|
|
210
|
+
function matchesTarget(values, target) {
|
|
211
|
+
if (!values || values.length === 0) return false;
|
|
212
|
+
|
|
213
|
+
for (const value of values) {
|
|
214
|
+
const url = typeof value === "string" ? value : value?.properties?.url?.[0];
|
|
215
|
+
if (url && normalizeUrl(url) === target) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Extract author from entry or page
|
|
225
|
+
* @param {object} entry - h-entry object
|
|
226
|
+
* @param {object} parsed - Full parsed microformats
|
|
227
|
+
* @returns {object|undefined} Author object
|
|
228
|
+
*/
|
|
229
|
+
function extractAuthor(entry, parsed) {
|
|
230
|
+
const author = getFirst(entry.properties?.author);
|
|
231
|
+
|
|
232
|
+
if (typeof author === "string") {
|
|
233
|
+
return { name: author };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (author?.type?.includes("h-card")) {
|
|
237
|
+
return {
|
|
238
|
+
type: "card",
|
|
239
|
+
name: getFirst(author.properties?.name),
|
|
240
|
+
url: getFirst(author.properties?.url),
|
|
241
|
+
photo: getFirst(author.properties?.photo),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Try to find author from page's h-card
|
|
246
|
+
const hcard = parsed.items.find((item) => item.type?.includes("h-card"));
|
|
247
|
+
if (hcard) {
|
|
248
|
+
return {
|
|
249
|
+
type: "card",
|
|
250
|
+
name: getFirst(hcard.properties?.name),
|
|
251
|
+
url: getFirst(hcard.properties?.url),
|
|
252
|
+
photo: getFirst(hcard.properties?.photo),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Extract content from entry
|
|
261
|
+
* @param {object} entry - h-entry object
|
|
262
|
+
* @returns {object|undefined} Content object
|
|
263
|
+
*/
|
|
264
|
+
function extractContent(entry) {
|
|
265
|
+
const content = getFirst(entry.properties?.content);
|
|
266
|
+
|
|
267
|
+
if (!content) {
|
|
268
|
+
const summary = getFirst(entry.properties?.summary);
|
|
269
|
+
const name = getFirst(entry.properties?.name);
|
|
270
|
+
return summary || name ? { text: summary || name } : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (typeof content === "string") {
|
|
274
|
+
return { text: content };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
text: content.value,
|
|
279
|
+
html: content.html,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get first item from array
|
|
285
|
+
* @param {Array|*} value - Value or array
|
|
286
|
+
* @returns {*} First value
|
|
287
|
+
*/
|
|
288
|
+
function getFirst(value) {
|
|
289
|
+
return Array.isArray(value) ? value[0] : value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Normalize URL for comparison
|
|
294
|
+
* @param {string} url - URL to normalize
|
|
295
|
+
* @returns {string} Normalized URL
|
|
296
|
+
*/
|
|
297
|
+
function normalizeUrl(url) {
|
|
298
|
+
return url.replace(/\/$/, "");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Escape special regex characters
|
|
303
|
+
* @param {string} string - String to escape
|
|
304
|
+
* @returns {string} Escaped string
|
|
305
|
+
*/
|
|
306
|
+
function escapeRegex(string) {
|
|
307
|
+
return string.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
|
308
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSub hub discovery
|
|
3
|
+
* @module websub/discovery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Discover WebSub hub from HTTP response headers and content
|
|
8
|
+
* @param {object} response - Fetch response object
|
|
9
|
+
* @param {string} content - Response body content
|
|
10
|
+
* @returns {object|undefined} WebSub info { hub, self }
|
|
11
|
+
*/
|
|
12
|
+
export function discoverWebsub(response, content) {
|
|
13
|
+
// Try to find hub and self URLs from Link headers first
|
|
14
|
+
const linkHeader = response.headers.get("link");
|
|
15
|
+
const fromHeaders = linkHeader ? parseLinkHeader(linkHeader) : {};
|
|
16
|
+
|
|
17
|
+
// Fall back to content parsing
|
|
18
|
+
const fromContent = parseContentForLinks(content);
|
|
19
|
+
|
|
20
|
+
const hub = fromHeaders.hub || fromContent.hub;
|
|
21
|
+
const self = fromHeaders.self || fromContent.self;
|
|
22
|
+
|
|
23
|
+
if (hub) {
|
|
24
|
+
return { hub, self };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse Link header for hub and self URLs
|
|
32
|
+
* @param {string} linkHeader - Link header value
|
|
33
|
+
* @returns {object} { hub, self }
|
|
34
|
+
*/
|
|
35
|
+
function parseLinkHeader(linkHeader) {
|
|
36
|
+
const result = {};
|
|
37
|
+
const links = linkHeader.split(",");
|
|
38
|
+
|
|
39
|
+
for (const link of links) {
|
|
40
|
+
const parts = link.trim().split(";");
|
|
41
|
+
if (parts.length < 2) continue;
|
|
42
|
+
|
|
43
|
+
const urlMatch = parts[0].match(/<([^>]+)>/);
|
|
44
|
+
if (!urlMatch) continue;
|
|
45
|
+
|
|
46
|
+
const url = urlMatch[1];
|
|
47
|
+
const relationship = parts
|
|
48
|
+
.slice(1)
|
|
49
|
+
.find((p) => p.trim().startsWith("rel="))
|
|
50
|
+
?.match(/rel=["']?([^"'\s;]+)["']?/)?.[1];
|
|
51
|
+
|
|
52
|
+
if (relationship === "hub") {
|
|
53
|
+
result.hub = url;
|
|
54
|
+
} else if (relationship === "self") {
|
|
55
|
+
result.self = url;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse content for hub and self URLs (Atom, RSS, HTML)
|
|
64
|
+
* @param {string} content - Response body
|
|
65
|
+
* @returns {object} { hub, self }
|
|
66
|
+
*/
|
|
67
|
+
function parseContentForLinks(content) {
|
|
68
|
+
const result = {};
|
|
69
|
+
|
|
70
|
+
// Try HTML <link> elements
|
|
71
|
+
const htmlHubMatch = content.match(
|
|
72
|
+
/<link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
|
|
73
|
+
);
|
|
74
|
+
if (htmlHubMatch) {
|
|
75
|
+
result.hub = htmlHubMatch[1];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const htmlSelfMatch = content.match(
|
|
79
|
+
/<link[^>]+rel=["']?self["']?[^>]+href=["']([^"']+)["']/i,
|
|
80
|
+
);
|
|
81
|
+
if (htmlSelfMatch) {
|
|
82
|
+
result.self = htmlSelfMatch[1];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Also try the reverse order (href before rel)
|
|
86
|
+
if (!result.hub) {
|
|
87
|
+
const htmlHubMatch2 = content.match(
|
|
88
|
+
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?hub["']?/i,
|
|
89
|
+
);
|
|
90
|
+
if (htmlHubMatch2) {
|
|
91
|
+
result.hub = htmlHubMatch2[1];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!result.self) {
|
|
96
|
+
const htmlSelfMatch2 = content.match(
|
|
97
|
+
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?self["']?/i,
|
|
98
|
+
);
|
|
99
|
+
if (htmlSelfMatch2) {
|
|
100
|
+
result.self = htmlSelfMatch2[1];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Try Atom <link> elements
|
|
105
|
+
if (!result.hub) {
|
|
106
|
+
const atomHubMatch = content.match(
|
|
107
|
+
/<atom:link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
|
|
108
|
+
);
|
|
109
|
+
if (atomHubMatch) {
|
|
110
|
+
result.hub = atomHubMatch[1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a hub URL is valid
|
|
119
|
+
* @param {string} hubUrl - Hub URL to validate
|
|
120
|
+
* @returns {boolean} Whether the URL is valid
|
|
121
|
+
*/
|
|
122
|
+
export function isValidHubUrl(hubUrl) {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(hubUrl);
|
|
125
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|