@rmdes/indiekit-endpoint-webmention-io 1.0.0

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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Webmentions MongoDB storage
3
+ */
4
+
5
+ import { extractDomain, sanitiseHtml } from "../utils.js";
6
+
7
+ /**
8
+ * Ensure indexes exist
9
+ * @param {object} collection - MongoDB collection
10
+ */
11
+ export async function ensureIndexes(collection) {
12
+ await collection.createIndex({ wmId: 1 }, { unique: true });
13
+ await collection.createIndex({ wmTarget: 1, hidden: 1 });
14
+ await collection.createIndex({ sourceDomain: 1 });
15
+ await collection.createIndex({ wmReceived: -1 });
16
+ }
17
+
18
+ /**
19
+ * Transform a JF2 webmention entry into our storage format
20
+ * @param {object} item - JF2 entry from webmention.io
21
+ * @returns {object} Document for MongoDB
22
+ */
23
+ export function jf2ToDocument(item) {
24
+ let contentHtml = null;
25
+ let contentText = null;
26
+
27
+ if (item.content?.html) {
28
+ contentHtml = sanitiseHtml(item.content.html);
29
+ } else if (item.content?.text) {
30
+ contentHtml = `<p>${item.content.text}</p>`;
31
+ }
32
+
33
+ if (item.content?.text) {
34
+ contentText = item.content.text;
35
+ }
36
+
37
+ return {
38
+ wmId: item["wm-id"],
39
+ wmReceived: item["wm-received"] ? new Date(item["wm-received"]) : new Date(),
40
+ wmProperty: item["wm-property"],
41
+ wmTarget: item["wm-target"],
42
+ authorName: item.author?.name || null,
43
+ authorUrl: item.author?.url || null,
44
+ authorPhoto: item.author?.photo || null,
45
+ sourceUrl: item.url || null,
46
+ sourceDomain: extractDomain(item.author?.url || item.url || ""),
47
+ published: item.published ? new Date(item.published) : null,
48
+ contentHtml,
49
+ contentText,
50
+ name: item.name || null,
51
+ hidden: false,
52
+ hiddenAt: null,
53
+ hiddenReason: null,
54
+ syncedAt: new Date(),
55
+ raw: item,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Convert a stored document back to JF2 format for the public API
61
+ * @param {object} doc - MongoDB document
62
+ * @returns {object} JF2 entry
63
+ */
64
+ export function documentToJf2(doc) {
65
+ const jf2 = {
66
+ type: "entry",
67
+ "wm-id": doc.wmId,
68
+ "wm-received": doc.wmReceived?.toISOString?.() || doc.wmReceived,
69
+ "wm-property": doc.wmProperty,
70
+ "wm-target": doc.wmTarget,
71
+ author: {
72
+ type: "card",
73
+ name: doc.authorName || "",
74
+ url: doc.authorUrl || "",
75
+ photo: doc.authorPhoto || "",
76
+ },
77
+ url: doc.sourceUrl || "",
78
+ published: doc.published?.toISOString?.() || doc.published || doc.wmReceived,
79
+ };
80
+
81
+ if (doc.name) {
82
+ jf2.name = doc.name;
83
+ }
84
+
85
+ if (doc.contentHtml || doc.contentText) {
86
+ jf2.content = {};
87
+ if (doc.contentHtml) jf2.content.html = doc.contentHtml;
88
+ if (doc.contentText) jf2.content.text = doc.contentText;
89
+ }
90
+
91
+ return jf2;
92
+ }
93
+
94
+ /**
95
+ * Upsert a webmention into MongoDB
96
+ * @param {object} collection - MongoDB collection
97
+ * @param {object} item - JF2 entry
98
+ * @returns {Promise<boolean>} true if inserted (new), false if updated
99
+ */
100
+ export async function upsertWebmention(collection, item) {
101
+ const doc = jf2ToDocument(item);
102
+ const result = await collection.updateOne(
103
+ { wmId: doc.wmId },
104
+ {
105
+ $setOnInsert: doc,
106
+ },
107
+ { upsert: true },
108
+ );
109
+ return result.upsertedCount > 0;
110
+ }
111
+
112
+ /**
113
+ * Get webmentions with filters
114
+ * @param {object} collection - MongoDB collection
115
+ * @param {object} options - Query options
116
+ * @returns {Promise<{items: Array, total: number}>}
117
+ */
118
+ export async function getWebmentions(collection, options = {}) {
119
+ const {
120
+ target,
121
+ wmProperty,
122
+ showHidden = false,
123
+ page = 0,
124
+ perPage = 20,
125
+ } = options;
126
+
127
+ const query = {};
128
+
129
+ if (!showHidden) {
130
+ query.hidden = { $ne: true };
131
+ }
132
+
133
+ if (target) {
134
+ // Match with and without trailing slash
135
+ const targetClean = target.replace(/\/$/, "");
136
+ query.wmTarget = { $in: [targetClean, targetClean + "/"] };
137
+ }
138
+
139
+ if (wmProperty) {
140
+ query.wmProperty = wmProperty;
141
+ }
142
+
143
+ const total = await collection.countDocuments(query);
144
+ const items = await collection
145
+ .find(query)
146
+ .sort({ wmReceived: -1 })
147
+ .skip(page * perPage)
148
+ .limit(perPage)
149
+ .toArray();
150
+
151
+ return { items, total };
152
+ }
153
+
154
+ /**
155
+ * Get webmention counts
156
+ * @param {object} collection - MongoDB collection
157
+ * @returns {Promise<{total: number, hidden: number, visible: number}>}
158
+ */
159
+ export async function getWebmentionCounts(collection) {
160
+ const total = await collection.countDocuments({});
161
+ const hidden = await collection.countDocuments({ hidden: true });
162
+ return { total, hidden, visible: total - hidden };
163
+ }
164
+
165
+ /**
166
+ * Get the highest wmId in the collection (for incremental sync)
167
+ * @param {object} collection - MongoDB collection
168
+ * @returns {Promise<number>} Highest wmId or 0
169
+ */
170
+ export async function getMaxWmId(collection) {
171
+ const result = await collection
172
+ .find({})
173
+ .sort({ wmId: -1 })
174
+ .limit(1)
175
+ .toArray();
176
+ return result.length > 0 ? result[0].wmId : 0;
177
+ }
178
+
179
+ /**
180
+ * Hide a webmention
181
+ * @param {object} collection - MongoDB collection
182
+ * @param {number} wmId - Webmention ID
183
+ * @param {string} reason - Reason ("manual", "blocklist", "privacy")
184
+ */
185
+ export async function hideWebmention(collection, wmId, reason = "manual") {
186
+ await collection.updateOne(
187
+ { wmId },
188
+ { $set: { hidden: true, hiddenAt: new Date(), hiddenReason: reason } },
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Unhide a webmention
194
+ * @param {object} collection - MongoDB collection
195
+ * @param {number} wmId - Webmention ID
196
+ */
197
+ export async function unhideWebmention(collection, wmId) {
198
+ await collection.updateOne(
199
+ { wmId },
200
+ { $set: { hidden: false, hiddenAt: null, hiddenReason: null } },
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Hide all webmentions from a domain
206
+ * @param {object} collection - MongoDB collection
207
+ * @param {string} domain - Domain to hide
208
+ * @param {string} reason - Reason
209
+ * @returns {Promise<number>} Number of mentions hidden
210
+ */
211
+ export async function hideByDomain(collection, domain, reason = "blocklist") {
212
+ const result = await collection.updateMany(
213
+ { sourceDomain: domain, hidden: { $ne: true } },
214
+ { $set: { hidden: true, hiddenAt: new Date(), hiddenReason: reason } },
215
+ );
216
+ return result.modifiedCount;
217
+ }
218
+
219
+ /**
220
+ * Unhide webmentions from a domain that were hidden by blocklist
221
+ * @param {object} collection - MongoDB collection
222
+ * @param {string} domain - Domain to unhide
223
+ * @returns {Promise<number>} Number of mentions unhidden
224
+ */
225
+ export async function unhideByDomain(collection, domain) {
226
+ const result = await collection.updateMany(
227
+ { sourceDomain: domain, hiddenReason: "blocklist" },
228
+ { $set: { hidden: false, hiddenAt: null, hiddenReason: null } },
229
+ );
230
+ return result.modifiedCount;
231
+ }
232
+
233
+ /**
234
+ * Permanently delete all webmentions from a domain (for privacy removal)
235
+ * @param {object} collection - MongoDB collection
236
+ * @param {string} domain - Domain
237
+ * @returns {Promise<number>} Number deleted
238
+ */
239
+ export async function deleteByDomain(collection, domain) {
240
+ const result = await collection.deleteMany({ sourceDomain: domain });
241
+ return result.deletedCount;
242
+ }
243
+
244
+ /**
245
+ * Delete all webmentions (for full re-sync)
246
+ * @param {object} collection - MongoDB collection
247
+ * @returns {Promise<number>} Number deleted
248
+ */
249
+ export async function deleteAll(collection) {
250
+ const result = await collection.deleteMany({});
251
+ return result.deletedCount;
252
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Background sync from webmention.io
3
+ */
4
+
5
+ import { extractDomain } from "./utils.js";
6
+ import {
7
+ ensureIndexes,
8
+ upsertWebmention,
9
+ getMaxWmId,
10
+ deleteAll,
11
+ hideByDomain,
12
+ } from "./storage/webmentions.js";
13
+ import {
14
+ ensureBlocklistIndexes,
15
+ getBlockedDomainSet,
16
+ } from "./storage/blocklist.js";
17
+
18
+ let syncInterval = null;
19
+ let syncState = {
20
+ lastSync: null,
21
+ syncing: false,
22
+ lastError: null,
23
+ mentionsAdded: 0,
24
+ mentionsFiltered: 0,
25
+ };
26
+
27
+ /**
28
+ * Get current sync state
29
+ * @returns {object}
30
+ */
31
+ export function getSyncState() {
32
+ return { ...syncState };
33
+ }
34
+
35
+ /**
36
+ * Start background sync
37
+ * @param {object} Indiekit - Indiekit instance
38
+ * @param {object} options - Plugin options
39
+ */
40
+ export function startSync(Indiekit, options) {
41
+ const intervalMs = options.syncInterval || 900_000; // 15 minutes
42
+
43
+ console.log(
44
+ `[Webmentions] Starting background sync with ${intervalMs / 60_000}min interval`,
45
+ );
46
+
47
+ // Initial sync after delay
48
+ setTimeout(() => {
49
+ runSync(Indiekit, options).catch((err) => {
50
+ console.error("[Webmentions] Initial sync error:", err.message);
51
+ });
52
+ }, 10_000);
53
+
54
+ // Recurring sync
55
+ syncInterval = setInterval(() => {
56
+ runSync(Indiekit, options).catch((err) => {
57
+ console.error("[Webmentions] Sync error:", err.message);
58
+ });
59
+ }, intervalMs);
60
+ }
61
+
62
+ /**
63
+ * Stop background sync
64
+ */
65
+ export function stopSync() {
66
+ if (syncInterval) {
67
+ clearInterval(syncInterval);
68
+ syncInterval = null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Run a single incremental sync cycle
74
+ * @param {object} dbOrIndiekit - Database or Indiekit instance
75
+ * @param {object} options - Plugin options
76
+ * @returns {Promise<object>}
77
+ */
78
+ export async function runSync(dbOrIndiekit, options) {
79
+ const db = dbOrIndiekit.database || dbOrIndiekit;
80
+ if (!db || typeof db.collection !== "function") {
81
+ syncState.lastError = "No database available";
82
+ return { error: syncState.lastError };
83
+ }
84
+
85
+ if (syncState.syncing) {
86
+ return { error: "Sync already in progress" };
87
+ }
88
+
89
+ syncState.syncing = true;
90
+ syncState.lastError = null;
91
+ syncState.mentionsAdded = 0;
92
+ syncState.mentionsFiltered = 0;
93
+
94
+ try {
95
+ const wmCollection = db.collection("webmentions");
96
+ const blockCollection = db.collection("webmentionBlocklist");
97
+
98
+ await ensureIndexes(wmCollection);
99
+ await ensureBlocklistIndexes(blockCollection);
100
+
101
+ // Get highest wmId for incremental sync
102
+ const sinceId = await getMaxWmId(wmCollection);
103
+
104
+ // Get blocked domains
105
+ const blockedDomains = await getBlockedDomainSet(blockCollection);
106
+
107
+ // Fetch pages from webmention.io
108
+ let page = 0;
109
+ let hasMore = true;
110
+ const perPage = 100;
111
+
112
+ while (hasMore) {
113
+ const items = await fetchPage(options, { page, perPage, sinceId });
114
+
115
+ if (!items || items.length === 0) {
116
+ hasMore = false;
117
+ break;
118
+ }
119
+
120
+ for (const item of items) {
121
+ const domain = extractDomain(item.author?.url || item.url || "");
122
+
123
+ if (domain && blockedDomains.has(domain)) {
124
+ syncState.mentionsFiltered++;
125
+ continue;
126
+ }
127
+
128
+ const isNew = await upsertWebmention(wmCollection, item);
129
+ if (isNew) {
130
+ syncState.mentionsAdded++;
131
+ }
132
+ }
133
+
134
+ page++;
135
+
136
+ // Rate limit: small delay between pages
137
+ if (items.length >= perPage) {
138
+ await delay(500);
139
+ } else {
140
+ hasMore = false;
141
+ }
142
+ }
143
+
144
+ syncState.lastSync = new Date();
145
+ syncState.syncing = false;
146
+
147
+ console.log(
148
+ `[Webmentions] Sync complete: ${syncState.mentionsAdded} new, ${syncState.mentionsFiltered} filtered`,
149
+ );
150
+
151
+ return {
152
+ mentionsAdded: syncState.mentionsAdded,
153
+ mentionsFiltered: syncState.mentionsFiltered,
154
+ };
155
+ } catch (error) {
156
+ syncState.lastError = error.message;
157
+ syncState.syncing = false;
158
+ console.error("[Webmentions] Sync failed:", error.message);
159
+ return { error: error.message };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Run a full re-sync (clear + fetch all)
165
+ * @param {object} dbOrIndiekit - Database or Indiekit instance
166
+ * @param {object} options - Plugin options
167
+ * @returns {Promise<object>}
168
+ */
169
+ export async function runFullSync(dbOrIndiekit, options) {
170
+ const db = dbOrIndiekit.database || dbOrIndiekit;
171
+ if (!db || typeof db.collection !== "function") {
172
+ return { error: "No database available" };
173
+ }
174
+
175
+ if (syncState.syncing) {
176
+ return { error: "Sync already in progress" };
177
+ }
178
+
179
+ syncState.syncing = true;
180
+ syncState.lastError = null;
181
+ syncState.mentionsAdded = 0;
182
+ syncState.mentionsFiltered = 0;
183
+
184
+ try {
185
+ const wmCollection = db.collection("webmentions");
186
+ const blockCollection = db.collection("webmentionBlocklist");
187
+
188
+ await ensureIndexes(wmCollection);
189
+ await ensureBlocklistIndexes(blockCollection);
190
+
191
+ // Clear all existing webmentions
192
+ const deleted = await deleteAll(wmCollection);
193
+ console.log(`[Webmentions] Full sync: cleared ${deleted} existing mentions`);
194
+
195
+ // Get blocked domains
196
+ const blockedDomains = await getBlockedDomainSet(blockCollection);
197
+
198
+ // Fetch ALL pages from webmention.io (no sinceId)
199
+ let page = 0;
200
+ let hasMore = true;
201
+ const perPage = 100;
202
+
203
+ while (hasMore) {
204
+ const items = await fetchPage(options, { page, perPage });
205
+
206
+ if (!items || items.length === 0) {
207
+ hasMore = false;
208
+ break;
209
+ }
210
+
211
+ for (const item of items) {
212
+ const domain = extractDomain(item.author?.url || item.url || "");
213
+
214
+ if (domain && blockedDomains.has(domain)) {
215
+ syncState.mentionsFiltered++;
216
+ continue;
217
+ }
218
+
219
+ const isNew = await upsertWebmention(wmCollection, item);
220
+ if (isNew) {
221
+ syncState.mentionsAdded++;
222
+ }
223
+ }
224
+
225
+ page++;
226
+
227
+ // Rate limit between pages
228
+ if (items.length >= perPage) {
229
+ await delay(1000);
230
+ } else {
231
+ hasMore = false;
232
+ }
233
+ }
234
+
235
+ syncState.lastSync = new Date();
236
+ syncState.syncing = false;
237
+
238
+ console.log(
239
+ `[Webmentions] Full sync complete: ${syncState.mentionsAdded} imported, ${syncState.mentionsFiltered} filtered`,
240
+ );
241
+
242
+ return {
243
+ mentionsAdded: syncState.mentionsAdded,
244
+ mentionsFiltered: syncState.mentionsFiltered,
245
+ };
246
+ } catch (error) {
247
+ syncState.lastError = error.message;
248
+ syncState.syncing = false;
249
+ console.error("[Webmentions] Full sync failed:", error.message);
250
+ return { error: error.message };
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Fetch a single page from webmention.io API
256
+ * @param {object} options - Plugin options (token, domain)
257
+ * @param {object} params - Fetch params (page, perPage, sinceId)
258
+ * @returns {Promise<Array>} Array of JF2 entries
259
+ */
260
+ async function fetchPage(options, params = {}) {
261
+ const url = new URL("https://webmention.io/api/mentions.jf2");
262
+ url.searchParams.set("token", options.token);
263
+ url.searchParams.set("domain", options.domain);
264
+ url.searchParams.set("per-page", String(params.perPage || 100));
265
+
266
+ if (params.page) {
267
+ url.searchParams.set("page", String(params.page));
268
+ }
269
+
270
+ if (params.sinceId) {
271
+ url.searchParams.set("since_id", String(params.sinceId));
272
+ }
273
+
274
+ const response = await fetch(url.href, {
275
+ headers: { accept: "application/json" },
276
+ signal: AbortSignal.timeout(15_000),
277
+ });
278
+
279
+ if (!response.ok) {
280
+ throw new Error(`webmention.io returned ${response.status}`);
281
+ }
282
+
283
+ const body = await response.json();
284
+ return body?.children || [];
285
+ }
286
+
287
+ function delay(ms) {
288
+ return new Promise((resolve) => setTimeout(resolve, ms));
289
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,115 @@
1
+ import sanitize from "sanitize-html";
2
+
3
+ /**
4
+ * Get mention type from `wm-property`
5
+ * @param {string} wmProperty - Webmention.io `wm-property` value
6
+ * @returns {string} Icon name
7
+ */
8
+ export const getMentionType = (wmProperty) => {
9
+ switch (true) {
10
+ case wmProperty === "in-reply-to": {
11
+ return "reply";
12
+ }
13
+ case wmProperty === "like-of": {
14
+ return "like";
15
+ }
16
+ case wmProperty === "repost-of": {
17
+ return "repost";
18
+ }
19
+ case wmProperty === "bookmark-of": {
20
+ return "bookmark";
21
+ }
22
+ case wmProperty === "rsvp": {
23
+ return "rsvp";
24
+ }
25
+ default: {
26
+ return "mention";
27
+ }
28
+ }
29
+ };
30
+
31
+ const upperFirst = (string) => {
32
+ return String(string).charAt(0).toUpperCase() + String(string).slice(1);
33
+ };
34
+
35
+ /**
36
+ * Get mention title
37
+ * @param {object} jf2 - JF2
38
+ * @returns {string} Mention title
39
+ */
40
+ export const getMentionTitle = (jf2) => {
41
+ let type = getMentionType(jf2["wm-property"]);
42
+ type = upperFirst(type).replace("Rsvp", "RSVP");
43
+
44
+ return jf2.name || type;
45
+ };
46
+
47
+ /**
48
+ * Get author name
49
+ * @param {object} jf2 - JF2
50
+ * @returns {string} Author name or URL fallback
51
+ */
52
+ export const getAuthorName = (jf2) => {
53
+ if (jf2.author?.name) return jf2.author.name;
54
+
55
+ try {
56
+ let url = jf2.author?.url || jf2.url;
57
+ url = new URL(url);
58
+ return url.hostname + url.pathname.replace(/\/$/, "");
59
+ } catch {
60
+ return "Unknown";
61
+ }
62
+ };
63
+
64
+ /**
65
+ * Normalise paragraphs
66
+ * @param {string} html - HTML
67
+ * @returns {string} HTML with normalised paragraphs
68
+ */
69
+ export const normaliseParagraphs = (html) => {
70
+ html = `<p>${html}</p>`;
71
+ html = html.replaceAll(/<br\s*\/?>\s*<br\s*\/?>/g, "</p><p>");
72
+ return html;
73
+ };
74
+
75
+ /**
76
+ * Sanitise incoming mention HTML
77
+ * @param {string} html - HTML
78
+ * @returns {string} Sanitised HTML
79
+ */
80
+ export const sanitiseHtml = (html) => {
81
+ html = normaliseParagraphs(html);
82
+ html = sanitize(html, {
83
+ exclusiveFilter: function (frame) {
84
+ return (
85
+ (frame.tag === "a" &&
86
+ frame.attribs?.href?.includes("brid.gy") &&
87
+ !frame.text.trim()) ||
88
+ (frame.tag === "p" && !frame.text.trim())
89
+ );
90
+ },
91
+ transformTags: {
92
+ h1: "h3",
93
+ h2: "h4",
94
+ h3: "h5",
95
+ h4: "h6",
96
+ h5: "h6",
97
+ h6: "h6",
98
+ },
99
+ });
100
+
101
+ return html;
102
+ };
103
+
104
+ /**
105
+ * Extract domain from a URL string
106
+ * @param {string} url - URL
107
+ * @returns {string|null} Domain or null
108
+ */
109
+ export const extractDomain = (url) => {
110
+ try {
111
+ return new URL(url).hostname;
112
+ } catch {
113
+ return null;
114
+ }
115
+ };