@rmdes/indiekit-endpoint-webmention-io 1.1.0 → 1.1.2

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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { blockDomain } from "../storage/blocklist.js";
7
+ import { normaliseDomain } from "../utils.js";
7
8
  import {
8
9
  getWebmentions,
9
10
  getWebmentionCounts,
@@ -208,11 +209,14 @@ export const dashboardController = {
208
209
  const wmCollection = database.collection("webmentions");
209
210
  const blockCollection = database.collection("webmentionBlocklist");
210
211
 
211
- // Hide all existing mentions from this domain
212
- const hidden = await hideByDomain(wmCollection, domain, "blocklist");
212
+ // Hide all existing mentions from this domain. Normalised because
213
+ // mentions store sourceDomain as a bare hostname while this form
214
+ // typically submits a full author URL.
215
+ const target = normaliseDomain(domain) ?? domain;
216
+ const hidden = await hideByDomain(wmCollection, target, "blocklist");
213
217
 
214
218
  // Add to blocklist
215
- await blockDomain(blockCollection, domain, "spam", hidden);
219
+ await blockDomain(blockCollection, target, "spam", hidden);
216
220
 
217
221
  response.redirect(
218
222
  application.webmentionEndpoint +
@@ -246,10 +250,11 @@ export const dashboardController = {
246
250
  const blockCollection = database.collection("webmentionBlocklist");
247
251
 
248
252
  // Permanently delete all mentions from this domain
249
- const deleted = await deleteByDomain(wmCollection, domain);
253
+ const target = normaliseDomain(domain) ?? domain;
254
+ const deleted = await deleteByDomain(wmCollection, target);
250
255
 
251
256
  // Add to blocklist with privacy reason
252
- await blockDomain(blockCollection, domain, "privacy", deleted);
257
+ await blockDomain(blockCollection, target, "privacy", deleted);
253
258
 
254
259
  response.redirect(
255
260
  application.webmentionEndpoint +
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Webmention blocklist MongoDB storage
3
+ *
4
+ * Domains are normalised to a bare hostname on the way in and on every
5
+ * lookup. The moderation forms submit whatever the operator was looking at,
6
+ * commonly a full author URL, while mentions store sourceDomain as a
7
+ * hostname. Storing the raw value made the blocklist silently non-matching.
3
8
  */
9
+ import { normaliseDomain } from "../utils.js";
4
10
 
5
11
  /**
6
12
  * Ensure indexes exist
@@ -24,6 +30,8 @@ export async function blockDomain(
24
30
  reason = "spam",
25
31
  mentionsHidden = 0,
26
32
  ) {
33
+ domain = normaliseDomain(domain) ?? domain;
34
+
27
35
  try {
28
36
  await collection.insertOne({
29
37
  domain,
@@ -55,7 +63,12 @@ export async function blockDomain(
55
63
  * @param {string} domain - Domain to unblock
56
64
  */
57
65
  export async function unblockDomain(collection, domain) {
58
- await collection.deleteOne({ domain });
66
+ const normalised = normaliseDomain(domain);
67
+ // Delete both shapes: entries written before normalisation still hold the
68
+ // raw value the operator pasted.
69
+ await collection.deleteMany({
70
+ domain: { $in: [domain, normalised].filter(Boolean) },
71
+ });
59
72
  }
60
73
 
61
74
  /**
@@ -74,7 +87,10 @@ export async function getBlocklist(collection) {
74
87
  * @returns {Promise<boolean>}
75
88
  */
76
89
  export async function isDomainBlocked(collection, domain) {
77
- const entry = await collection.findOne({ domain });
90
+ const normalised = normaliseDomain(domain);
91
+ const entry = await collection.findOne({
92
+ domain: { $in: [domain, normalised].filter(Boolean) },
93
+ });
78
94
  return !!entry;
79
95
  }
80
96
 
@@ -87,5 +103,9 @@ export async function getBlockedDomainSet(collection) {
87
103
  const entries = await collection
88
104
  .find({}, { projection: { domain: 1 } })
89
105
  .toArray();
90
- return new Set(entries.map((e) => e.domain));
106
+ // Normalised on read as well as write, so entries stored before this was
107
+ // fixed start matching without needing a migration.
108
+ return new Set(
109
+ entries.map((e) => normaliseDomain(e.domain) ?? e.domain).filter(Boolean),
110
+ );
91
111
  }
package/lib/sync.js CHANGED
@@ -109,6 +109,22 @@ export async function runSync(dbOrIndiekit, options) {
109
109
  // Get blocked domains
110
110
  const blockedDomains = await getBlockedDomainSet(blockCollection);
111
111
 
112
+ // Enforce the blocklist over mentions already stored, not just incoming
113
+ // ones. Blocking hid what was present at the time and nothing after, so a
114
+ // mention that arrived before the block - or was restored by a later
115
+ // sync - stayed visible with no way to clear it short of blocking again.
116
+ // hideByDomain only touches rows not already hidden, so this is cheap and
117
+ // idempotent on every cycle.
118
+ let sweptHidden = 0;
119
+ for (const blocked of blockedDomains) {
120
+ sweptHidden += await hideByDomain(wmCollection, blocked, "blocklist");
121
+ }
122
+ if (sweptHidden > 0) {
123
+ console.log(
124
+ `[Webmentions] Blocklist sweep hid ${sweptHidden} existing mention(s)`,
125
+ );
126
+ }
127
+
112
128
  // Fetch pages from webmention.io
113
129
  let page = 0;
114
130
  let hasMore = true;
package/lib/utils.js CHANGED
@@ -50,15 +50,17 @@ export const getMentionTitle = (jf2) => {
50
50
  * @returns {string} Author name or URL fallback
51
51
  */
52
52
  export const getAuthorName = (jf2) => {
53
- if (jf2.author?.name) return jf2.author.name;
53
+ if (jf2.author?.name) {
54
+ return jf2.author.name;
55
+ }
54
56
 
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";
57
+ const url = jf2.author?.url || jf2.url;
58
+ if (!URL.canParse(url)) {
59
+ return url;
61
60
  }
61
+
62
+ const { hostname, pathname } = new URL(url);
63
+ return hostname + pathname.replace(/\/$/, "");
62
64
  };
63
65
 
64
66
  /**
@@ -121,6 +123,36 @@ export const ensureISOString = (value) => {
121
123
  * @param {string} url - URL
122
124
  * @returns {string|null} Domain or null
123
125
  */
126
+ /**
127
+ * Reduce a user-supplied value to the bare hostname stored on a webmention.
128
+ *
129
+ * Mentions carry sourceDomain as a hostname ("rmendes.net"), but the
130
+ * moderation forms hand back whatever the operator had in front of them -
131
+ * often the full author URL ("https://rmendes.net"). Blocking stored that
132
+ * verbatim and matched it verbatim against sourceDomain, so it matched
133
+ * nothing: the blocklist entry appeared, mentionsHidden was 0, and the
134
+ * mentions stayed visible. extractDomain alone will not do, since it returns
135
+ * null for a bare hostname.
136
+ * @param {string} value - Hostname or URL
137
+ * @returns {string|null} Lowercased hostname, or null if unparseable
138
+ */
139
+ export const normaliseDomain = (value) => {
140
+ if (!value) return null;
141
+
142
+ const trimmed = String(value).trim().toLowerCase();
143
+ if (!trimmed) return null;
144
+
145
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//.test(trimmed)
146
+ ? trimmed
147
+ : `https://${trimmed}`;
148
+
149
+ try {
150
+ return new URL(withScheme).hostname || null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ };
155
+
124
156
  export const extractDomain = (url) => {
125
157
  try {
126
158
  return new URL(url).hostname;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-webmention-io",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Webmention moderation endpoint for Indiekit. Syncs webmentions from webmention.io into MongoDB with delete, block, and privacy removal capabilities.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -38,12 +38,10 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "@indiekit/error": "^1.0.0-beta.28",
41
+ "@rmdes/indiekit-startup-gate": "^1.0.0",
41
42
  "express": "^5.2.1",
42
43
  "sanitize-html": "^2.17.7"
43
44
  },
44
- "peerDependencies": {
45
- "@indiekit/indiekit": ">=1.0.0-beta.25"
46
- },
47
45
  "publishConfig": {
48
46
  "access": "public"
49
47
  },