@rmdes/indiekit-endpoint-webmention-io 1.1.2 → 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 +33 -6
- 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
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* Webmentions MongoDB storage
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
extractDomain,
|
|
7
|
+
ensureISOString,
|
|
8
|
+
normaliseDomain,
|
|
9
|
+
sanitiseHtml,
|
|
10
|
+
} from "../utils.js";
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Ensure indexes exist
|
|
@@ -99,8 +104,17 @@ export function documentToJf2(doc) {
|
|
|
99
104
|
* @param {object} item - JF2 entry
|
|
100
105
|
* @returns {Promise<boolean>} true if inserted (new), false if updated
|
|
101
106
|
*/
|
|
102
|
-
export async function upsertWebmention(collection, item) {
|
|
107
|
+
export async function upsertWebmention(collection, item, options = {}) {
|
|
103
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
|
+
}
|
|
104
118
|
const result = await collection.updateOne(
|
|
105
119
|
{ wmId: document.wmId },
|
|
106
120
|
{
|
|
@@ -121,6 +135,7 @@ export async function getWebmentions(collection, options = {}) {
|
|
|
121
135
|
const {
|
|
122
136
|
target,
|
|
123
137
|
wmProperty,
|
|
138
|
+
hiddenReason,
|
|
124
139
|
showHidden = false,
|
|
125
140
|
page = 0,
|
|
126
141
|
perPage = 20,
|
|
@@ -142,6 +157,11 @@ export async function getWebmentions(collection, options = {}) {
|
|
|
142
157
|
query.wmProperty = wmProperty;
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
// The review queue: mentions held because their domain is quarantined.
|
|
161
|
+
if (hiddenReason) {
|
|
162
|
+
query.hiddenReason = hiddenReason;
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
const total = await collection.countDocuments(query);
|
|
146
166
|
const items = await collection
|
|
147
167
|
.find(query)
|
|
@@ -161,7 +181,12 @@ export async function getWebmentions(collection, options = {}) {
|
|
|
161
181
|
export async function getWebmentionCounts(collection) {
|
|
162
182
|
const total = await collection.countDocuments({});
|
|
163
183
|
const hidden = await collection.countDocuments({ hidden: true });
|
|
164
|
-
|
|
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 };
|
|
165
190
|
}
|
|
166
191
|
|
|
167
192
|
/**
|
|
@@ -218,7 +243,7 @@ export async function unhideWebmention(collection, wmId) {
|
|
|
218
243
|
*/
|
|
219
244
|
export async function hideByDomain(collection, domain, reason = "blocklist") {
|
|
220
245
|
const result = await collection.updateMany(
|
|
221
|
-
{ sourceDomain: domain, hidden: { $ne: true } },
|
|
246
|
+
{ sourceDomain: normaliseDomain(domain) ?? domain, hidden: { $ne: true } },
|
|
222
247
|
{
|
|
223
248
|
$set: {
|
|
224
249
|
hidden: true,
|
|
@@ -238,7 +263,7 @@ export async function hideByDomain(collection, domain, reason = "blocklist") {
|
|
|
238
263
|
*/
|
|
239
264
|
export async function unhideByDomain(collection, domain) {
|
|
240
265
|
const result = await collection.updateMany(
|
|
241
|
-
{ sourceDomain: domain, hiddenReason: "blocklist" },
|
|
266
|
+
{ sourceDomain: normaliseDomain(domain) ?? domain, hiddenReason: "blocklist" },
|
|
242
267
|
{ $set: { hidden: false, hiddenAt: null, hiddenReason: null } },
|
|
243
268
|
);
|
|
244
269
|
return result.modifiedCount;
|
|
@@ -251,7 +276,9 @@ export async function unhideByDomain(collection, domain) {
|
|
|
251
276
|
* @returns {Promise<number>} Number deleted
|
|
252
277
|
*/
|
|
253
278
|
export async function deleteByDomain(collection, domain) {
|
|
254
|
-
const result = await collection.deleteMany({
|
|
279
|
+
const result = await collection.deleteMany({
|
|
280
|
+
sourceDomain: normaliseDomain(domain) ?? domain,
|
|
281
|
+
});
|
|
255
282
|
return result.deletedCount;
|
|
256
283
|
}
|
|
257
284
|
|
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") }}:
|