@rmdes/indiekit-endpoint-webmention-io 1.1.3 → 1.2.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.
- package/index.js +6 -0
- package/lib/controllers/dashboard.js +50 -1
- package/lib/storage/blocklist.js +35 -1
- package/lib/storage/webmentions.js +22 -2
- package/lib/sync.js +29 -7
- package/locales/en.json +25 -3
- package/package.json +1 -1
- package/views/webmentions-blocklist.njk +135 -156
- package/views/webmentions.njk +4 -0
package/index.js
CHANGED
|
@@ -74,6 +74,12 @@ export default class WebmentionEndpoint {
|
|
|
74
74
|
// Block a domain (hides all mentions + adds to blocklist)
|
|
75
75
|
protectedRouter.post("/block", dashboardController.blockDomainHandler);
|
|
76
76
|
|
|
77
|
+
// Quarantine a domain (hold future mentions, leave history visible)
|
|
78
|
+
protectedRouter.post(
|
|
79
|
+
"/quarantine",
|
|
80
|
+
dashboardController.quarantineDomainHandler,
|
|
81
|
+
);
|
|
82
|
+
|
|
77
83
|
// Unblock a domain
|
|
78
84
|
protectedRouter.post(
|
|
79
85
|
"/blocklist/:domain/delete",
|
|
@@ -58,7 +58,11 @@ export const dashboardController = {
|
|
|
58
58
|
perPage: limit,
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
-
if (filter === "
|
|
61
|
+
if (filter === "quarantine") {
|
|
62
|
+
// Held for review: stored, hidden, and waiting on a decision.
|
|
63
|
+
queryOptions.showHidden = true;
|
|
64
|
+
queryOptions.hiddenReason = "quarantine";
|
|
65
|
+
} else if (filter === "hidden") {
|
|
62
66
|
queryOptions.showHidden = true;
|
|
63
67
|
} else if (filter === "visible") {
|
|
64
68
|
queryOptions.showHidden = false;
|
|
@@ -234,6 +238,51 @@ export const dashboardController = {
|
|
|
234
238
|
* @param request
|
|
235
239
|
* @param response
|
|
236
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* POST /quarantine - hold a domain's future mentions for review
|
|
243
|
+
*
|
|
244
|
+
* Deliberately does not touch what is already stored: quarantine is for a
|
|
245
|
+
* domain the operator is unsure about, so the history stays visible while
|
|
246
|
+
* anything new waits for a decision. Promoting to a block later sweeps the
|
|
247
|
+
* whole history, because blockDomainHandler does.
|
|
248
|
+
* @type {import("express").RequestHandler}
|
|
249
|
+
*/
|
|
250
|
+
async quarantineDomainHandler(request, response) {
|
|
251
|
+
const { application } = request.app.locals;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const { domain, reason } = request.body;
|
|
255
|
+
if (!domain) {
|
|
256
|
+
return response.redirect(
|
|
257
|
+
application.webmentionEndpoint + "/blocklist?error=no-domain",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const database = application.getWebmentionDb();
|
|
262
|
+
const blockCollection = database.collection("webmentionBlocklist");
|
|
263
|
+
const target = normaliseDomain(domain) ?? domain;
|
|
264
|
+
|
|
265
|
+
await blockDomain(
|
|
266
|
+
blockCollection,
|
|
267
|
+
target,
|
|
268
|
+
reason || "unsure",
|
|
269
|
+
0,
|
|
270
|
+
"quarantined",
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
response.redirect(
|
|
274
|
+
application.webmentionEndpoint +
|
|
275
|
+
"/blocklist?quarantined=1&domain=" +
|
|
276
|
+
encodeURIComponent(target),
|
|
277
|
+
);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error("[Webmentions] Quarantine error:", error);
|
|
280
|
+
response.redirect(
|
|
281
|
+
application.webmentionEndpoint + "/blocklist?error=quarantine-failed",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
|
|
237
286
|
async privacyRemove(request, response) {
|
|
238
287
|
const { application } = request.app.locals;
|
|
239
288
|
|
package/lib/storage/blocklist.js
CHANGED
|
@@ -29,6 +29,7 @@ export async function blockDomain(
|
|
|
29
29
|
domain,
|
|
30
30
|
reason = "spam",
|
|
31
31
|
mentionsHidden = 0,
|
|
32
|
+
status = "blocked",
|
|
32
33
|
) {
|
|
33
34
|
domain = normaliseDomain(domain) ?? domain;
|
|
34
35
|
|
|
@@ -36,6 +37,7 @@ export async function blockDomain(
|
|
|
36
37
|
await collection.insertOne({
|
|
37
38
|
domain,
|
|
38
39
|
reason,
|
|
40
|
+
status,
|
|
39
41
|
blockedAt: new Date().toISOString(),
|
|
40
42
|
mentionsHidden,
|
|
41
43
|
});
|
|
@@ -44,10 +46,12 @@ export async function blockDomain(
|
|
|
44
46
|
// Duplicate key — domain already blocked
|
|
45
47
|
if (error.code === 11_000) {
|
|
46
48
|
// Update reason and count
|
|
49
|
+
// Re-applying an action to a listed domain also moves it between
|
|
50
|
+
// quarantine and block, which is how "promote to block" works.
|
|
47
51
|
await collection.updateOne(
|
|
48
52
|
{ domain },
|
|
49
53
|
{
|
|
50
|
-
$set: { reason },
|
|
54
|
+
$set: { reason, status },
|
|
51
55
|
$inc: { mentionsHidden },
|
|
52
56
|
},
|
|
53
57
|
);
|
|
@@ -94,6 +98,36 @@ export async function isDomainBlocked(collection, domain) {
|
|
|
94
98
|
return !!entry;
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Split the list by what each entry actually enforces.
|
|
103
|
+
*
|
|
104
|
+
* The two differ in one respect, and it is the whole point of quarantine:
|
|
105
|
+
* a blocked domain has its stored mentions swept hidden on every cycle, a
|
|
106
|
+
* quarantined one never does. Incoming mentions are handled differently too —
|
|
107
|
+
* blocked are dropped, quarantined are stored hidden for review.
|
|
108
|
+
*
|
|
109
|
+
* Entries written before quarantine existed carry no status and count as
|
|
110
|
+
* blocked, so nothing needs migrating.
|
|
111
|
+
* @param {object} collection - MongoDB collection
|
|
112
|
+
* @returns {Promise<{blocked: Set<string>, quarantined: Set<string>}>} Sets
|
|
113
|
+
*/
|
|
114
|
+
export async function getModerationSets(collection) {
|
|
115
|
+
const entries = await collection
|
|
116
|
+
.find({}, { projection: { domain: 1, status: 1 } })
|
|
117
|
+
.toArray();
|
|
118
|
+
|
|
119
|
+
const blocked = new Set();
|
|
120
|
+
const quarantined = new Set();
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const domain = normaliseDomain(entry.domain) ?? entry.domain;
|
|
124
|
+
if (!domain) continue;
|
|
125
|
+
(entry.status === "quarantined" ? quarantined : blocked).add(domain);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { blocked, quarantined };
|
|
129
|
+
}
|
|
130
|
+
|
|
97
131
|
/**
|
|
98
132
|
* Get set of all blocked domains (for efficient sync filtering)
|
|
99
133
|
* @param {object} collection - MongoDB collection
|
|
@@ -104,8 +104,17 @@ export function documentToJf2(doc) {
|
|
|
104
104
|
* @param {object} item - JF2 entry
|
|
105
105
|
* @returns {Promise<boolean>} true if inserted (new), false if updated
|
|
106
106
|
*/
|
|
107
|
-
export async function upsertWebmention(collection, item) {
|
|
107
|
+
export async function upsertWebmention(collection, item, options = {}) {
|
|
108
108
|
const document = jf2ToDocument(item);
|
|
109
|
+
|
|
110
|
+
// A quarantined domain's mentions are kept rather than dropped, but arrive
|
|
111
|
+
// hidden so the operator can approve them one by one. $setOnInsert means an
|
|
112
|
+
// existing mention is never re-hidden by a later sync.
|
|
113
|
+
if (options.quarantined) {
|
|
114
|
+
document.hidden = true;
|
|
115
|
+
document.hiddenAt = new Date().toISOString();
|
|
116
|
+
document.hiddenReason = "quarantine";
|
|
117
|
+
}
|
|
109
118
|
const result = await collection.updateOne(
|
|
110
119
|
{ wmId: document.wmId },
|
|
111
120
|
{
|
|
@@ -126,6 +135,7 @@ export async function getWebmentions(collection, options = {}) {
|
|
|
126
135
|
const {
|
|
127
136
|
target,
|
|
128
137
|
wmProperty,
|
|
138
|
+
hiddenReason,
|
|
129
139
|
showHidden = false,
|
|
130
140
|
page = 0,
|
|
131
141
|
perPage = 20,
|
|
@@ -147,6 +157,11 @@ export async function getWebmentions(collection, options = {}) {
|
|
|
147
157
|
query.wmProperty = wmProperty;
|
|
148
158
|
}
|
|
149
159
|
|
|
160
|
+
// The review queue: mentions held because their domain is quarantined.
|
|
161
|
+
if (hiddenReason) {
|
|
162
|
+
query.hiddenReason = hiddenReason;
|
|
163
|
+
}
|
|
164
|
+
|
|
150
165
|
const total = await collection.countDocuments(query);
|
|
151
166
|
const items = await collection
|
|
152
167
|
.find(query)
|
|
@@ -166,7 +181,12 @@ export async function getWebmentions(collection, options = {}) {
|
|
|
166
181
|
export async function getWebmentionCounts(collection) {
|
|
167
182
|
const total = await collection.countDocuments({});
|
|
168
183
|
const hidden = await collection.countDocuments({ hidden: true });
|
|
169
|
-
|
|
184
|
+
// Quarantined mentions are a subset of hidden: held for a decision rather
|
|
185
|
+
// than hidden by a block.
|
|
186
|
+
const quarantined = await collection.countDocuments({
|
|
187
|
+
hiddenReason: "quarantine",
|
|
188
|
+
});
|
|
189
|
+
return { total, hidden, quarantined, visible: total - hidden };
|
|
170
190
|
}
|
|
171
191
|
|
|
172
192
|
/**
|
package/lib/sync.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { discoverAuthorData } from "./hcard.js";
|
|
6
6
|
import {
|
|
7
7
|
ensureBlocklistIndexes,
|
|
8
|
-
|
|
8
|
+
getModerationSets,
|
|
9
9
|
} from "./storage/blocklist.js";
|
|
10
10
|
import {
|
|
11
11
|
ensureIndexes,
|
|
@@ -25,6 +25,7 @@ let syncState = {
|
|
|
25
25
|
lastError: null,
|
|
26
26
|
mentionsAdded: 0,
|
|
27
27
|
mentionsFiltered: 0,
|
|
28
|
+
mentionsQuarantined: 0,
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -95,6 +96,7 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
95
96
|
syncState.lastError = null;
|
|
96
97
|
syncState.mentionsAdded = 0;
|
|
97
98
|
syncState.mentionsFiltered = 0;
|
|
99
|
+
syncState.mentionsQuarantined = 0;
|
|
98
100
|
|
|
99
101
|
try {
|
|
100
102
|
const wmCollection = database.collection("webmentions");
|
|
@@ -107,7 +109,8 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
107
109
|
const sinceId = await getMaxWmId(wmCollection);
|
|
108
110
|
|
|
109
111
|
// Get blocked domains
|
|
110
|
-
const blockedDomains
|
|
112
|
+
const { blocked: blockedDomains, quarantined: quarantinedDomains } =
|
|
113
|
+
await getModerationSets(blockCollection);
|
|
111
114
|
|
|
112
115
|
// Enforce the blocklist over mentions already stored, not just incoming
|
|
113
116
|
// ones. Blocking hid what was present at the time and nothing after, so a
|
|
@@ -146,9 +149,16 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
146
149
|
continue;
|
|
147
150
|
}
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
// Quarantined domains are kept, not dropped: stored hidden so the
|
|
153
|
+
// operator can approve or promote to a block after looking.
|
|
154
|
+
const quarantined = Boolean(domain && quarantinedDomains.has(domain));
|
|
155
|
+
|
|
156
|
+
const isNew = await upsertWebmention(wmCollection, item, {
|
|
157
|
+
quarantined,
|
|
158
|
+
});
|
|
150
159
|
if (isNew) {
|
|
151
160
|
syncState.mentionsAdded++;
|
|
161
|
+
if (quarantined) syncState.mentionsQuarantined++;
|
|
152
162
|
}
|
|
153
163
|
}
|
|
154
164
|
|
|
@@ -169,12 +179,13 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
169
179
|
syncState.syncing = false;
|
|
170
180
|
|
|
171
181
|
console.log(
|
|
172
|
-
`[Webmentions] Sync complete: ${syncState.mentionsAdded} new, ${syncState.mentionsFiltered} filtered, ${enriched} enriched`,
|
|
182
|
+
`[Webmentions] Sync complete: ${syncState.mentionsAdded} new, ${syncState.mentionsFiltered} filtered, ${syncState.mentionsQuarantined} quarantined, ${enriched} enriched`,
|
|
173
183
|
);
|
|
174
184
|
|
|
175
185
|
return {
|
|
176
186
|
mentionsAdded: syncState.mentionsAdded,
|
|
177
187
|
mentionsFiltered: syncState.mentionsFiltered,
|
|
188
|
+
mentionsQuarantined: syncState.mentionsQuarantined,
|
|
178
189
|
mentionsEnriched: enriched,
|
|
179
190
|
};
|
|
180
191
|
} catch (error) {
|
|
@@ -205,6 +216,7 @@ export async function runFullSync(dbOrIndiekit, options) {
|
|
|
205
216
|
syncState.lastError = null;
|
|
206
217
|
syncState.mentionsAdded = 0;
|
|
207
218
|
syncState.mentionsFiltered = 0;
|
|
219
|
+
syncState.mentionsQuarantined = 0;
|
|
208
220
|
|
|
209
221
|
try {
|
|
210
222
|
const wmCollection = database.collection("webmentions");
|
|
@@ -219,8 +231,13 @@ export async function runFullSync(dbOrIndiekit, options) {
|
|
|
219
231
|
`[Webmentions] Full sync: cleared ${deleted} existing mentions`,
|
|
220
232
|
);
|
|
221
233
|
|
|
222
|
-
// Get blocked domains
|
|
223
|
-
|
|
234
|
+
// Get blocked domains. A full sync has just deleted everything, so
|
|
235
|
+
// every mention it re-fetches counts as new — including those from a
|
|
236
|
+
// quarantined domain, which therefore come back held for review rather
|
|
237
|
+
// than visible. Failing closed is the safer reading: the operator asked
|
|
238
|
+
// for that domain to wait on a decision.
|
|
239
|
+
const { blocked: blockedDomains, quarantined: quarantinedDomains } =
|
|
240
|
+
await getModerationSets(blockCollection);
|
|
224
241
|
|
|
225
242
|
// Fetch ALL pages from webmention.io (no sinceId)
|
|
226
243
|
let page = 0;
|
|
@@ -243,9 +260,14 @@ export async function runFullSync(dbOrIndiekit, options) {
|
|
|
243
260
|
continue;
|
|
244
261
|
}
|
|
245
262
|
|
|
246
|
-
const
|
|
263
|
+
const quarantined = Boolean(domain && quarantinedDomains.has(domain));
|
|
264
|
+
|
|
265
|
+
const isNew = await upsertWebmention(wmCollection, item, {
|
|
266
|
+
quarantined,
|
|
267
|
+
});
|
|
247
268
|
if (isNew) {
|
|
248
269
|
syncState.mentionsAdded++;
|
|
270
|
+
if (quarantined) syncState.mentionsQuarantined++;
|
|
249
271
|
}
|
|
250
272
|
}
|
|
251
273
|
|
package/locales/en.json
CHANGED
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"likes": "Likes",
|
|
31
31
|
"replies": "Replies",
|
|
32
32
|
"reposts": "Reposts",
|
|
33
|
-
"mentions": "Mentions"
|
|
33
|
+
"mentions": "Mentions",
|
|
34
|
+
"quarantine": "Held for review"
|
|
34
35
|
},
|
|
35
36
|
"actions": {
|
|
36
37
|
"hide": "Hide",
|
|
@@ -64,12 +65,33 @@
|
|
|
64
65
|
"removed": "mentions permanently deleted",
|
|
65
66
|
"unhidden": "mentions restored",
|
|
66
67
|
"mentionsHidden": "mentions hidden",
|
|
67
|
-
"blockedAt": "Blocked"
|
|
68
|
+
"blockedAt": "Blocked",
|
|
69
|
+
"statusLabel": "Status",
|
|
70
|
+
"statusBlocked": "Blocked",
|
|
71
|
+
"statusQuarantined": "Quarantined",
|
|
72
|
+
"howItWorks": "What each action does",
|
|
73
|
+
"howQuarantine": "Quarantine — mentions already published stay visible. Anything new from this domain is kept but held for review, so you can approve it or block the domain once you have seen it. Reversible.",
|
|
74
|
+
"howBlock": "Block — every mention from this domain is hidden, both those already stored and anything that arrives later. Nothing is deleted, so unblocking restores them. Reversible.",
|
|
75
|
+
"howPrivacy": "Privacy removal — every mention from this domain is deleted from the database and the domain is listed. This cannot be undone.",
|
|
76
|
+
"quarantine": {
|
|
77
|
+
"title": "Quarantine a domain",
|
|
78
|
+
"description": "For a domain you are unsure about. What it has already sent stays visible; what it sends next waits for your decision in the review queue.",
|
|
79
|
+
"button": "Quarantine domain",
|
|
80
|
+
"quarantined": "Domain quarantined. New mentions from it will be held for review.",
|
|
81
|
+
"promote": "Block",
|
|
82
|
+
"promoteConfirm": "Block %s? This also hides every mention it has already sent."
|
|
83
|
+
}
|
|
68
84
|
},
|
|
69
85
|
"counts": {
|
|
70
86
|
"total": "total",
|
|
71
87
|
"hidden": "hidden",
|
|
72
|
-
"visible": "visible"
|
|
88
|
+
"visible": "visible",
|
|
89
|
+
"quarantined": "held for review"
|
|
90
|
+
},
|
|
91
|
+
"review": {
|
|
92
|
+
"empty": "Nothing is waiting for review.",
|
|
93
|
+
"approve": "Approve",
|
|
94
|
+
"heldFrom": "Held because %s is quarantined"
|
|
73
95
|
}
|
|
74
96
|
}
|
|
75
97
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-webmention-io",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -1,178 +1,157 @@
|
|
|
1
1
|
{% extends "document.njk" %}
|
|
2
2
|
|
|
3
3
|
{% block content %}
|
|
4
|
+
{# Only the table keeps local styles - the frontend has no table component.
|
|
5
|
+
Everything else uses the macros default.njk exposes. #}
|
|
4
6
|
<style>
|
|
5
|
-
.bl-section {
|
|
6
|
-
background: var(--color-offset, #f5f5f5);
|
|
7
|
-
border-radius: var(--border-radius-small, 0.5rem);
|
|
8
|
-
padding: var(--space-m, 1.5rem);
|
|
9
|
-
margin-block-end: var(--space-m, 1.5rem);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
.bl-section h2 {
|
|
13
|
-
font: var(--font-heading, bold 1.25rem/1.4 sans-serif);
|
|
14
|
-
margin-block-end: var(--space-xs, 0.5rem);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.bl-section__desc {
|
|
18
|
-
color: var(--color-on-offset, #666);
|
|
19
|
-
font: var(--font-body, 0.875rem/1.5 sans-serif);
|
|
20
|
-
margin-block-end: var(--space-s, 0.75rem);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
.bl-form {
|
|
24
|
-
display: flex;
|
|
25
|
-
gap: var(--space-xs, 0.5rem);
|
|
26
|
-
align-items: flex-end;
|
|
27
|
-
flex-wrap: wrap;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
.bl-form .field {
|
|
31
|
-
flex: 1;
|
|
32
|
-
min-width: 200px;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
7
|
.bl-table {
|
|
36
|
-
width: 100%;
|
|
37
8
|
border-collapse: collapse;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.bl-table th {
|
|
42
|
-
text-align: left;
|
|
43
|
-
font-weight: 600;
|
|
44
|
-
padding: var(--space-xs, 0.5rem);
|
|
45
|
-
border-block-end: 2px solid var(--color-outline-variant, #ddd);
|
|
9
|
+
inline-size: 100%;
|
|
10
|
+
margin-block-start: var(--space-s);
|
|
46
11
|
}
|
|
47
12
|
|
|
13
|
+
.bl-table th,
|
|
48
14
|
.bl-table td {
|
|
49
|
-
|
|
50
|
-
|
|
15
|
+
border-block-end: 1px solid var(--color-border);
|
|
16
|
+
padding: var(--space-2xs) var(--space-3xs);
|
|
17
|
+
text-align: start;
|
|
51
18
|
vertical-align: middle;
|
|
52
19
|
}
|
|
53
20
|
|
|
54
|
-
.bl-
|
|
55
|
-
display:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.bl-badge--privacy {
|
|
63
|
-
background: var(--color-error-container, #f8d7da);
|
|
64
|
-
color: var(--color-error, #dc3545);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.bl-badge--spam {
|
|
68
|
-
background: var(--color-warning-container, #fff3cd);
|
|
69
|
-
color: var(--color-warning, #856404);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.bl-empty {
|
|
73
|
-
color: var(--color-on-offset, #666);
|
|
74
|
-
text-align: center;
|
|
75
|
-
padding: var(--space-m, 1rem);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.bl-success {
|
|
79
|
-
background: var(--color-success-container, #d4edda);
|
|
80
|
-
border: 1px solid var(--color-success, #28a745);
|
|
81
|
-
border-radius: var(--border-radius-small, 0.25rem);
|
|
82
|
-
padding: var(--space-s, 0.75rem);
|
|
83
|
-
margin-block-end: var(--space-m, 1rem);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
.bl-back {
|
|
87
|
-
margin-block-end: var(--space-m, 1rem);
|
|
21
|
+
.bl-form {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-wrap: wrap;
|
|
24
|
+
gap: var(--space-s);
|
|
25
|
+
align-items: flex-end;
|
|
26
|
+
margin-block-end: var(--space-l);
|
|
88
27
|
}
|
|
89
28
|
</style>
|
|
90
29
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
30
|
+
{{ backLink({
|
|
31
|
+
href: wmEndpoint,
|
|
32
|
+
text: __("webmention-io.title")
|
|
33
|
+
}) }}
|
|
94
34
|
|
|
95
|
-
{# Flash messages #}
|
|
96
35
|
{% if request.query.unblocked %}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
36
|
+
{{ notificationBanner({
|
|
37
|
+
type: "success",
|
|
38
|
+
text: __("webmention-io.blocklist.unblocked") + " — " + (request.query.unhidden or 0) + " " + __("webmention-io.blocklist.unhidden")
|
|
39
|
+
}) }}
|
|
40
|
+
{% endif %}
|
|
41
|
+
{% if request.query.blocked %}
|
|
42
|
+
{{ notificationBanner({ type: "success", text: __("webmention-io.blocklist.blocked") }) }}
|
|
43
|
+
{% endif %}
|
|
44
|
+
{% if request.query.quarantined %}
|
|
45
|
+
{{ notificationBanner({ type: "success", text: __("webmention-io.blocklist.quarantine.quarantined") }) }}
|
|
100
46
|
{% endif %}
|
|
101
47
|
{% if request.query.removed %}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
48
|
+
{{ notificationBanner({
|
|
49
|
+
type: "success",
|
|
50
|
+
text: (request.query.count or 0) + " " + __("webmention-io.blocklist.removed")
|
|
51
|
+
}) }}
|
|
52
|
+
{% endif %}
|
|
53
|
+
{% if request.query.error %}
|
|
54
|
+
{{ notificationBanner({ type: "error", text: request.query.error }) }}
|
|
105
55
|
{% endif %}
|
|
106
56
|
|
|
107
|
-
{#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<p
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
57
|
+
{# The three actions differ in ways their buttons do not convey, so say so. #}
|
|
58
|
+
{{ details({
|
|
59
|
+
summary: __("webmention-io.blocklist.howItWorks"),
|
|
60
|
+
text: "<p>" + __("webmention-io.blocklist.howQuarantine") + "</p><p>" + __("webmention-io.blocklist.howBlock") + "</p><p>" + __("webmention-io.blocklist.howPrivacy") + "</p>"
|
|
61
|
+
}) }}
|
|
62
|
+
|
|
63
|
+
{{ heading({ level: 2, text: __("webmention-io.blocklist.quarantine.title") }) }}
|
|
64
|
+
<p>{{ __("webmention-io.blocklist.quarantine.description") }}</p>
|
|
65
|
+
|
|
66
|
+
<form method="post" action="{{ wmEndpoint }}/quarantine" class="bl-form">
|
|
67
|
+
{{ input({
|
|
68
|
+
id: "quarantine-domain",
|
|
69
|
+
name: "domain",
|
|
70
|
+
label: __("webmention-io.blocklist.domainLabel"),
|
|
71
|
+
hint: __("webmention-io.blocklist.domainPlaceholder"),
|
|
72
|
+
required: true
|
|
73
|
+
}) }}
|
|
74
|
+
{{ button({ text: __("webmention-io.blocklist.quarantine.button") }) }}
|
|
75
|
+
</form>
|
|
76
|
+
|
|
77
|
+
{{ heading({ level: 2, text: __("webmention-io.blocklist.add") }) }}
|
|
78
|
+
<p>{{ __("webmention-io.blocklist.description") }}</p>
|
|
79
|
+
|
|
80
|
+
<form method="post" action="{{ wmEndpoint }}/block" class="bl-form">
|
|
81
|
+
{{ input({
|
|
82
|
+
id: "block-domain",
|
|
83
|
+
name: "domain",
|
|
84
|
+
label: __("webmention-io.blocklist.domainLabel"),
|
|
85
|
+
hint: __("webmention-io.blocklist.domainPlaceholder"),
|
|
86
|
+
required: true
|
|
87
|
+
}) }}
|
|
88
|
+
{{ button({ text: __("webmention-io.blocklist.blockButton") }) }}
|
|
89
|
+
</form>
|
|
90
|
+
|
|
91
|
+
{{ heading({ level: 2, text: __("webmention-io.blocklist.privacy.title") }) }}
|
|
92
|
+
{{ warningText({ text: __("webmention-io.blocklist.howPrivacy") }) }}
|
|
93
|
+
|
|
94
|
+
<form method="post" action="{{ wmEndpoint }}/privacy-remove" class="bl-form"
|
|
95
|
+
onsubmit="return confirm('{{ __("webmention-io.blocklist.howPrivacy") }}')">
|
|
96
|
+
{{ input({
|
|
97
|
+
id: "privacy-domain",
|
|
98
|
+
name: "domain",
|
|
99
|
+
label: __("webmention-io.blocklist.privacy.domainLabel"),
|
|
100
|
+
hint: __("webmention-io.blocklist.domainPlaceholder"),
|
|
101
|
+
required: true
|
|
102
|
+
}) }}
|
|
103
|
+
{{ button({ text: __("webmention-io.blocklist.privacy.removeButton") }) }}
|
|
104
|
+
</form>
|
|
105
|
+
|
|
106
|
+
{{ heading({
|
|
107
|
+
level: 2,
|
|
108
|
+
text: __("webmention-io.blocklist.title") + " (" + entries.length + ")"
|
|
109
|
+
}) }}
|
|
110
|
+
|
|
111
|
+
{% if entries.length > 0 %}
|
|
112
|
+
<table class="bl-table">
|
|
113
|
+
<thead>
|
|
114
|
+
<tr>
|
|
115
|
+
<th>{{ __("webmention-io.blocklist.domainLabel") }}</th>
|
|
116
|
+
<th>{{ __("webmention-io.blocklist.statusLabel") }}</th>
|
|
117
|
+
<th>{{ __("webmention-io.blocklist.reasonLabel") }}</th>
|
|
118
|
+
<th>{{ __("webmention-io.blocklist.mentionsHidden") }}</th>
|
|
119
|
+
<th>{{ __("webmention-io.blocklist.blockedAt") }}</th>
|
|
120
|
+
<th></th>
|
|
121
|
+
</tr>
|
|
122
|
+
</thead>
|
|
123
|
+
<tbody>
|
|
124
|
+
{% for entry in entries %}
|
|
125
|
+
<tr>
|
|
126
|
+
<td><strong>{{ entry.domain }}</strong></td>
|
|
127
|
+
<td>
|
|
128
|
+
{% if entry.status == "quarantined" %}
|
|
129
|
+
{{ badge({ color: "yellow", text: __("webmention-io.blocklist.statusQuarantined") }) }}
|
|
130
|
+
{% else %}
|
|
131
|
+
{{ badge({ color: "red", text: __("webmention-io.blocklist.statusBlocked") }) }}
|
|
132
|
+
{% endif %}
|
|
133
|
+
</td>
|
|
134
|
+
<td>{{ badge({ color: "purple", size: "small", text: entry.reason }) }}</td>
|
|
135
|
+
<td>{{ entry.mentionsHidden or 0 }}</td>
|
|
136
|
+
<td>{% if entry.blockedAt %}{{ entry.blockedAt | date("PPp") }}{% endif %}</td>
|
|
137
|
+
<td>
|
|
138
|
+
{# Promoting a quarantine to a block sweeps the domain's history,
|
|
139
|
+
because that is what blockDomainHandler already does. #}
|
|
140
|
+
{% if entry.status == "quarantined" %}
|
|
141
|
+
<form method="post" action="{{ wmEndpoint }}/block" style="display:inline">
|
|
142
|
+
<input type="hidden" name="domain" value="{{ entry.domain }}">
|
|
143
|
+
{{ button({ text: __("webmention-io.blocklist.quarantine.promote") }) }}
|
|
144
|
+
</form>
|
|
145
|
+
{% endif %}
|
|
146
|
+
<form method="post" action="{{ wmEndpoint }}/blocklist/{{ entry.domain | urlencode }}/delete" style="display:inline">
|
|
147
|
+
{{ button({ text: __("webmention-io.blocklist.unblock") }) }}
|
|
148
|
+
</form>
|
|
149
|
+
</td>
|
|
150
|
+
</tr>
|
|
151
|
+
{% endfor %}
|
|
152
|
+
</tbody>
|
|
153
|
+
</table>
|
|
154
|
+
{% else %}
|
|
155
|
+
<p>{{ __("webmention-io.blocklist.empty") }}</p>
|
|
156
|
+
{% endif %}
|
|
178
157
|
{% endblock %}
|
package/views/webmentions.njk
CHANGED
|
@@ -133,6 +133,9 @@
|
|
|
133
133
|
</span>
|
|
134
134
|
<span class="wm-stats__item">
|
|
135
135
|
<span class="wm-stats__count">{{ counts.total }}</span> {{ __("webmention-io.counts.total") }}
|
|
136
|
+
</div>
|
|
137
|
+
<div>
|
|
138
|
+
<span class="wm-stats__count">{{ counts.quarantined or 0 }}</span> {{ __("webmention-io.counts.quarantined") }}
|
|
136
139
|
</span>
|
|
137
140
|
</div>
|
|
138
141
|
|
|
@@ -162,6 +165,7 @@
|
|
|
162
165
|
<a href="{{ wmEndpoint }}?filter=all&type={{ typeFilter }}" class="{% if filter == 'all' %}active{% endif %}">{{ __("webmention-io.filter.all") }}</a>
|
|
163
166
|
<a href="{{ wmEndpoint }}?filter=visible&type={{ typeFilter }}" class="{% if filter == 'visible' %}active{% endif %}">{{ __("webmention-io.filter.visible") }}</a>
|
|
164
167
|
<a href="{{ wmEndpoint }}?filter=hidden&type={{ typeFilter }}" class="{% if filter == 'hidden' %}active{% endif %}">{{ __("webmention-io.filter.hidden") }}</a>
|
|
168
|
+
<a href="{{ wmEndpoint }}?filter=quarantine&type={{ typeFilter }}" class="{% if filter == 'quarantine' %}active{% endif %}">{{ __("webmention-io.filter.quarantine") }}{% if counts.quarantined %} ({{ counts.quarantined }}){% endif %}</a>
|
|
165
169
|
</div>
|
|
166
170
|
<div>
|
|
167
171
|
{{ __("webmention-io.filter.type") }}:
|