@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 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 === "hidden") {
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
 
@@ -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 { extractDomain, ensureISOString, sanitiseHtml } from "../utils.js";
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
- return { total, hidden, visible: total - hidden };
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({ sourceDomain: domain });
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
- getBlockedDomainSet,
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 = await getBlockedDomainSet(blockCollection);
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
- const isNew = await upsertWebmention(wmCollection, item);
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
- const blockedDomains = await getBlockedDomainSet(blockCollection);
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 isNew = await upsertWebmention(wmCollection, item);
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.1.2",
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
- font: var(--font-body, 0.875rem/1.5 sans-serif);
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
- padding: var(--space-xs, 0.5rem);
50
- border-block-end: 1px solid var(--color-outline-variant, #eee);
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-badge {
55
- display: inline-block;
56
- font: var(--font-caption, 0.6875rem/1.4 sans-serif);
57
- padding: 0.125rem 0.375rem;
58
- border-radius: var(--border-radius-small, 0.25rem);
59
- background: var(--color-offset, #e9ecef);
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
- <div class="bl-back">
92
- <a href="{{ wmEndpoint }}">&larr; {{ __("webmention-io.title") }}</a>
93
- </div>
30
+ {{ backLink({
31
+ href: wmEndpoint,
32
+ text: __("webmention-io.title")
33
+ }) }}
94
34
 
95
- {# Flash messages #}
96
35
  {% if request.query.unblocked %}
97
- <div class="bl-success">
98
- {{ __("webmention-io.blocklist.unblocked") }} — {{ request.query.unhidden or 0 }} {{ __("webmention-io.blocklist.unhidden") }}
99
- </div>
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
- <div class="bl-success">
103
- {{ request.query.count or 0 }} {{ __("webmention-io.blocklist.removed") }}
104
- </div>
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
- {# Add to blocklist #}
108
- <section class="bl-section">
109
- <h2>{{ __("webmention-io.blocklist.add") }}</h2>
110
- <p class="bl-section__desc">{{ __("webmention-io.blocklist.description") }}</p>
111
-
112
- <form method="post" action="{{ wmEndpoint }}/block" class="bl-form">
113
- <div class="field">
114
- <label class="label">{{ __("webmention-io.blocklist.domainLabel") }}</label>
115
- <input type="text" name="domain" class="input" placeholder="{{ __("webmention-io.blocklist.domainPlaceholder") }}" required>
116
- </div>
117
- <button type="submit" class="button button--primary">{{ __("webmention-io.blocklist.blockButton") }}</button>
118
- </form>
119
- </section>
120
-
121
- {# Privacy removal #}
122
- <section class="bl-section">
123
- <h2>{{ __("webmention-io.blocklist.privacy.title") }}</h2>
124
- <p class="bl-section__desc">{{ __("webmention-io.blocklist.privacy.description") }}</p>
125
-
126
- <form method="post" action="{{ wmEndpoint }}/privacy-remove" class="bl-form"
127
- onsubmit="return confirm('This will PERMANENTLY DELETE all webmentions from this domain and block it. This cannot be undone. Continue?')">
128
- <div class="field">
129
- <label class="label">{{ __("webmention-io.blocklist.privacy.domainLabel") }}</label>
130
- <input type="text" name="domain" class="input" placeholder="{{ __("webmention-io.blocklist.domainPlaceholder") }}" required>
131
- </div>
132
- <button type="submit" class="button button--primary">{{ __("webmention-io.blocklist.privacy.removeButton") }}</button>
133
- </form>
134
- </section>
135
-
136
- {# Blocklist table #}
137
- <section class="bl-section">
138
- <h2>{{ __("webmention-io.blocklist.title") }} ({{ entries.length }})</h2>
139
-
140
- {% if entries.length > 0 %}
141
- <table class="bl-table">
142
- <thead>
143
- <tr>
144
- <th>{{ __("webmention-io.blocklist.domainLabel") }}</th>
145
- <th>{{ __("webmention-io.blocklist.reasonLabel") }}</th>
146
- <th>{{ __("webmention-io.blocklist.mentionsHidden") }}</th>
147
- <th>{{ __("webmention-io.blocklist.blockedAt") }}</th>
148
- <th></th>
149
- </tr>
150
- </thead>
151
- <tbody>
152
- {% for entry in entries %}
153
- <tr>
154
- <td><strong>{{ entry.domain }}</strong></td>
155
- <td>
156
- <span class="bl-badge {% if entry.reason == 'privacy' %}bl-badge--privacy{% elif entry.reason == 'spam' %}bl-badge--spam{% endif %}">
157
- {{ entry.reason }}
158
- </span>
159
- </td>
160
- <td>{{ entry.mentionsHidden or 0 }}</td>
161
- <td>{% if entry.blockedAt %}{{ entry.blockedAt | date("PPp") }}{% endif %}</td>
162
- <td>
163
- <form method="post" action="{{ wmEndpoint }}/blocklist/{{ entry.domain | urlencode }}/delete" style="display:inline">
164
- <button type="submit" class="button button--small button--secondary"
165
- onclick="return confirm('Unblock {{ entry.domain }}? Mentions hidden by this block will be restored.')">
166
- {{ __("webmention-io.blocklist.unblock") }}
167
- </button>
168
- </form>
169
- </td>
170
- </tr>
171
- {% endfor %}
172
- </tbody>
173
- </table>
174
- {% else %}
175
- <p class="bl-empty">{{ __("webmention-io.blocklist.empty") }}</p>
176
- {% endif %}
177
- </section>
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 %}
@@ -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") }}: