@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.
- package/lib/controllers/dashboard.js +10 -5
- package/lib/storage/blocklist.js +23 -3
- package/lib/sync.js +16 -0
- package/lib/utils.js +39 -7
- package/package.json +2 -4
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
257
|
+
await blockDomain(blockCollection, target, "privacy", deleted);
|
|
253
258
|
|
|
254
259
|
response.redirect(
|
|
255
260
|
application.webmentionEndpoint +
|
package/lib/storage/blocklist.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
53
|
+
if (jf2.author?.name) {
|
|
54
|
+
return jf2.author.name;
|
|
55
|
+
}
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
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
|
},
|