domma-cms 0.82.0 → 0.84.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/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/login.html +24 -0
- package/admin/js/views/login.js +7 -7
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +46 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +64 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +135 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +184 -39
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - the admin screen: Overview (health score and checks), Sign-ins
|
|
3
|
+
* (the log) and Blocked (lockouts, blocks for probing, rules). Settings in a
|
|
4
|
+
* slideover behind the banner cog; Refresh beside it.
|
|
5
|
+
*
|
|
6
|
+
* The Tool upgrade path: domma-reactive state (M.observable / computed /
|
|
7
|
+
* effect, M.applyBindings), context menus on rows (right-click, ⋮, Shift+F10),
|
|
8
|
+
* help popovers, theme tokens, a sidebar badge (GET /count). Emails, addresses
|
|
9
|
+
* and browsers come from the public: they reach the DOM through data-bind-text
|
|
10
|
+
* only, and rows are addressed by index.
|
|
11
|
+
* `#/plugins/security/<tab>` opens a tab. Pure logic: ../lib/*.js.
|
|
12
|
+
*/
|
|
13
|
+
import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
|
|
14
|
+
import {ipRuleValid} from '../lib/ip.js';
|
|
15
|
+
|
|
16
|
+
const API = '/plugins/security';
|
|
17
|
+
const ROUTE = /^#\/plugins\/security(\/[a-z]+)?\/?(\?.*)?$/;
|
|
18
|
+
const TABS = ['overview', 'signins', 'blocked'];
|
|
19
|
+
const SIDEBAR_URL = '#/plugins/security';
|
|
20
|
+
const GLYPH = {pass: '✓', warn: '!', fail: '✕', info: 'i'};
|
|
21
|
+
const REASONS = {password: 'Wrong password', unknown: 'No such account', inactive: 'Account switched off', denied: 'Refused (locked)', challenge: 'Wrong code'};
|
|
22
|
+
|
|
23
|
+
let kit = null;
|
|
24
|
+
async function loadKit() {
|
|
25
|
+
if (kit) return kit;
|
|
26
|
+
const [api, chrome, help, tk] = await Promise.all([
|
|
27
|
+
import('/admin/js/api.js'),
|
|
28
|
+
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
29
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({})),
|
|
30
|
+
import('/admin/js/lib/tool-kit.js')
|
|
31
|
+
]);
|
|
32
|
+
kit = {
|
|
33
|
+
apiRequest: api.apiRequest,
|
|
34
|
+
addBannerAction: chrome.addBannerAction || (() => null),
|
|
35
|
+
helpPopovers: help.helpPopovers || (() => () => {}),
|
|
36
|
+
onceClosed: tk.onceClosed, openMenuAt: tk.openMenuAt, viewLifetime: tk.viewLifetime
|
|
37
|
+
};
|
|
38
|
+
return kit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
42
|
+
function fragment(html) {
|
|
43
|
+
const t = document.createElement('template');
|
|
44
|
+
t.innerHTML = html.trim();
|
|
45
|
+
return t.content.firstElementChild;
|
|
46
|
+
}
|
|
47
|
+
async function copy(text, what) {
|
|
48
|
+
try { await navigator.clipboard.writeText(text); E.toast(`Copied ${what}.`, {type: 'success'}); }
|
|
49
|
+
catch { E.toast(`Could not copy - ${text}`, {type: 'warning', duration: 6000}); }
|
|
50
|
+
}
|
|
51
|
+
function refreshBadge() {
|
|
52
|
+
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
|
|
53
|
+
}
|
|
54
|
+
const when = (iso) => {
|
|
55
|
+
const d = new Date(iso);
|
|
56
|
+
const today = new Date().toDateString() === d.toDateString();
|
|
57
|
+
const t = d.toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'});
|
|
58
|
+
return today ? `Today ${t}` : `${d.toLocaleDateString('en-GB', {day: 'numeric', month: 'short'})} ${t}`;
|
|
59
|
+
};
|
|
60
|
+
const until = (ms) => `until ${new Date(ms).toLocaleString('en-GB', {day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'})}`;
|
|
61
|
+
/** "Chrome on Windows" from a user-agent - enough to recognise one. */
|
|
62
|
+
function browserOf(ua) {
|
|
63
|
+
const s = String(ua || '');
|
|
64
|
+
if (!s) return 'Unknown';
|
|
65
|
+
const b = /Edg\//.test(s) ? 'Edge' : /OPR\//.test(s) ? 'Opera' : /Firefox\//.test(s) ? 'Firefox' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /curl|python|wget|go-http/i.test(s) ? 'A script' : 'Other';
|
|
66
|
+
const o = /iPhone|iPad/.test(s) ? 'iOS' : /Android/.test(s) ? 'Android' : /Windows/.test(s) ? 'Windows' : /Mac OS X/.test(s) ? 'macOS' : /Linux/.test(s) ? 'Linux' : '';
|
|
67
|
+
return o ? `${b} on ${o}` : b;
|
|
68
|
+
}
|
|
69
|
+
/** CSV with formula-leading cells defused (the log holds what strangers typed). */
|
|
70
|
+
function toCsv(rows) {
|
|
71
|
+
const cell = (v) => {
|
|
72
|
+
let s = String(v ?? '');
|
|
73
|
+
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
|
74
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
75
|
+
};
|
|
76
|
+
const cols = Object.keys(rows[0] || {});
|
|
77
|
+
return `${[cols.join(','), ...rows.map(r => cols.map(c => cell(r[c])).join(','))].join('\r\n')}\r\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CHECK_ROW = `
|
|
81
|
+
<div class="scx-item">
|
|
82
|
+
<div class="scx-group" data-bind-hidden="!head" data-bind-text="head"></div>
|
|
83
|
+
<div class="scx-check" data-bind-hidden="!title" data-bind-data-row="id" tabindex="0">
|
|
84
|
+
<span class="scx-status" data-bind-data-status="status" data-bind-text="glyph" data-bind-title="statusText"></span>
|
|
85
|
+
<span class="scx-check-main">
|
|
86
|
+
<strong data-bind-text="title"></strong>
|
|
87
|
+
<small data-bind-text="detail"></small>
|
|
88
|
+
<span class="scx-more" data-bind-hidden="!open">
|
|
89
|
+
<span><b>Why it matters.</b> <span data-bind-text="why"></span></span>
|
|
90
|
+
<span><b>How to fix it.</b> <span data-bind-text="fix"></span></span>
|
|
91
|
+
<a class="btn btn-xs btn-ghost" data-bind-href="link" data-bind-hidden="!link">Go there</a>
|
|
92
|
+
</span>
|
|
93
|
+
</span>
|
|
94
|
+
<button type="button" class="scx-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>`;
|
|
97
|
+
const SIGNIN_ROW = `
|
|
98
|
+
<div class="scx-row" data-bind-data-row="id" tabindex="0" data-bind-title="ua">
|
|
99
|
+
<span class="scx-cell" data-bind-text="when"></span>
|
|
100
|
+
<span class="scx-pill" data-bind-data-ok="ok" data-bind-text="okText"></span>
|
|
101
|
+
<span class="scx-cell" data-bind-text="email"></span>
|
|
102
|
+
<span class="scx-cell scx-mono scx-hide-sm" data-bind-text="ip"></span>
|
|
103
|
+
<span class="scx-cell scx-muted scx-hide-sm" data-bind-text="note"></span>
|
|
104
|
+
<button type="button" class="scx-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
105
|
+
</div>`;
|
|
106
|
+
const PLAIN_ROW = `
|
|
107
|
+
<div class="scx-row" data-bind-data-row="id" tabindex="0">
|
|
108
|
+
<span class="scx-cell scx-mono" data-bind-text="who" data-bind-title="who"></span>
|
|
109
|
+
<span class="scx-cell scx-muted" data-bind-text="why" data-bind-title="why"></span>
|
|
110
|
+
<span class="scx-cell scx-muted" data-bind-text="when"></span>
|
|
111
|
+
<button type="button" class="scx-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
112
|
+
</div>`;
|
|
113
|
+
|
|
114
|
+
export const securityView = {
|
|
115
|
+
templateUrl: '/plugins/security/admin/templates/security.html',
|
|
116
|
+
|
|
117
|
+
async onMount($container) {
|
|
118
|
+
const k = await loadKit();
|
|
119
|
+
if (!ROUTE.test(location.hash)) return;
|
|
120
|
+
const lifetime = (securityView._lifetime ??= k.viewLifetime(ROUTE));
|
|
121
|
+
lifetime.reset();
|
|
122
|
+
const root = $container.get(0).querySelector('.scx') || $container.get(0);
|
|
123
|
+
const here = () => ROUTE.test(location.hash) && root.isConnected;
|
|
124
|
+
const scope = (fn) => { lifetime.add(fn); return fn; };
|
|
125
|
+
const api = (path, opts) => k.apiRequest(`${API}${path}`, opts);
|
|
126
|
+
|
|
127
|
+
// ---- State ----
|
|
128
|
+
const fromHash = location.hash.match(/security\/([a-z]+)/)?.[1];
|
|
129
|
+
const tab = M.observable(TABS.includes(fromHash) ? fromHash : 'overview');
|
|
130
|
+
const loading = M.observable(true);
|
|
131
|
+
const error = M.observable('');
|
|
132
|
+
const health = M.observable(null);
|
|
133
|
+
const overview = M.observable(null);
|
|
134
|
+
const signins = M.observable([]);
|
|
135
|
+
const blocks = M.observable({rules: [], temp: [], locks: [], you: ''});
|
|
136
|
+
const settings = M.observable(mergeDefaults(DEFAULT_SETTINGS));
|
|
137
|
+
const checksOk = M.observable(true);
|
|
138
|
+
const canManage = M.observable(true);
|
|
139
|
+
const openChecks = M.observable(new Set());
|
|
140
|
+
const result = M.observable('all');
|
|
141
|
+
const days = M.observable('7');
|
|
142
|
+
const query = M.observable('');
|
|
143
|
+
const newRule = M.observable('');
|
|
144
|
+
const newNote = M.observable('');
|
|
145
|
+
const ipFilter = M.observable('');
|
|
146
|
+
|
|
147
|
+
// ---- Derived ----
|
|
148
|
+
const scoreText = M.computed(() => (health.value ? `${health.value.score}%` : '—'));
|
|
149
|
+
const scoreWidth = M.computed(() => `${health.value?.score ?? 0}%`);
|
|
150
|
+
const scoreTone = M.computed(() => { const s = health.value?.score; return s === undefined ? '' : s >= 85 ? 'good' : s >= 60 ? 'poor' : 'bad'; });
|
|
151
|
+
const attentionText = M.computed(() => (health.value ? String(health.value.failing) : '—'));
|
|
152
|
+
const asOf = M.computed(() => (health.value?.at ? `Checked ${new Date(health.value.at).toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'})}` : ''));
|
|
153
|
+
const ov = M.computed(() => {
|
|
154
|
+
const o = overview.value;
|
|
155
|
+
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0)} : {failed: '—', blocked: '—', probes: '—'};
|
|
156
|
+
});
|
|
157
|
+
const blockedCount = M.computed(() => { const b = blocks.value; const n = b.rules.length + b.temp.length + b.locks.length; return n ? String(n) : ''; });
|
|
158
|
+
|
|
159
|
+
const checks = M.observable([]);
|
|
160
|
+
let checkList = [];
|
|
161
|
+
scope(M.effect(() => {
|
|
162
|
+
const h = health.value;
|
|
163
|
+
const open = openChecks.value;
|
|
164
|
+
const order = {fail: 0, warn: 1, info: 3, pass: 2};
|
|
165
|
+
const list = [...(h?.checks || [])];
|
|
166
|
+
const GROUPS = ['Sign-in', 'People', 'Web', 'Server', 'Plugins'];
|
|
167
|
+
const groups = [...new Set(list.map(c => c.group))].sort((a, b) => (GROUPS.indexOf(a) + 1 || 99) - (GROUPS.indexOf(b) + 1 || 99));
|
|
168
|
+
checkList = [];
|
|
169
|
+
const rows = [];
|
|
170
|
+
for (const g of groups) {
|
|
171
|
+
rows.push({id: `g-${g}`, head: g, title: '', detail: '', status: '', glyph: '', statusText: '', open: false, why: '', fix: '', link: ''});
|
|
172
|
+
for (const c of list.filter(x => x.group === g).sort((a, b) => order[a.status] - order[b.status])) {
|
|
173
|
+
checkList.push(c);
|
|
174
|
+
rows.push({id: `c${checkList.length - 1}`, head: '', title: c.title, detail: c.detail, status: c.status, glyph: GLYPH[c.status] || '?',
|
|
175
|
+
statusText: {pass: 'Passes', warn: 'Could be better', fail: 'Failing', info: 'For information'}[c.status] || '',
|
|
176
|
+
open: open.has(c.id), why: c.why || '', fix: c.fix || '', link: c.link || ''});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
checks.value = rows;
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
const signinRows = M.observable([]);
|
|
183
|
+
let signinList = [];
|
|
184
|
+
scope(M.effect(() => {
|
|
185
|
+
const q = query.value.trim().toLowerCase();
|
|
186
|
+
const r = result.value;
|
|
187
|
+
const only = ipFilter.value;
|
|
188
|
+
signinList = signins.value.filter(e => (r === 'all' || (r === 'ok') === Boolean(e.ok))
|
|
189
|
+
&& (!only || e.ip === only)
|
|
190
|
+
&& (!q || [e.email, e.ip, e.ua, e.reason].some(v => String(v || '').toLowerCase().includes(q))));
|
|
191
|
+
signinRows.value = signinList.slice(0, 1000).map((e, i) => ({
|
|
192
|
+
id: `s${i}`, when: when(e.at), ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
|
|
193
|
+
email: e.email || '—', ip: e.ip || '—', ua: e.ua || '',
|
|
194
|
+
note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`
|
|
195
|
+
}));
|
|
196
|
+
}));
|
|
197
|
+
const signinText = M.computed(() => {
|
|
198
|
+
const n = signinList.length + 0 * signinRows.value.length;
|
|
199
|
+
const f = ipFilter.value ? ` from ${ipFilter.value}` : '';
|
|
200
|
+
return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
|
|
201
|
+
});
|
|
202
|
+
const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
|
|
203
|
+
|
|
204
|
+
const lockRows = M.observable([]);
|
|
205
|
+
const tempRows = M.observable([]);
|
|
206
|
+
const ruleRows = M.observable([]);
|
|
207
|
+
scope(M.effect(() => {
|
|
208
|
+
const b = blocks.value;
|
|
209
|
+
lockRows.value = b.locks.map((l, i) => ({id: `l${i}`, who: l.who, why: l.kind === 'account' ? `Account - ${l.locks > 1 ? `locked ${l.locks} times` : 'wrong passwords'}` : 'Address - wrong passwords', when: until(l.until)}));
|
|
210
|
+
tempRows.value = b.temp.map((t, i) => ({id: `t${i}`, who: t.ip, why: t.reason || 'Probing', when: until(t.until)}));
|
|
211
|
+
ruleRows.value = b.rules.map((r, i) => ({id: `r${i}`, who: r.rule, why: r.note || (r.by ? `Added by ${r.by}` : 'Blocked by hand'), when: r.at ? `since ${new Date(r.at).toLocaleDateString('en-GB', {day: 'numeric', month: 'short', year: 'numeric'})}` : ''}));
|
|
212
|
+
}));
|
|
213
|
+
const youText = M.computed(() => (blocks.value.you ? `You are ${blocks.value.you}` : ''));
|
|
214
|
+
|
|
215
|
+
// ---- Server ----
|
|
216
|
+
async function loadHealth(refresh = false) {
|
|
217
|
+
try {
|
|
218
|
+
const h = await api(`/health${refresh ? '?refresh=1' : ''}`);
|
|
219
|
+
if (here() && h) { health.value = h; error.value = ''; }
|
|
220
|
+
if (refresh) refreshBadge();
|
|
221
|
+
} catch (e) {
|
|
222
|
+
if (here()) error.value = /403|forbidden/i.test(String(e.message)) ? 'You do not have access to Security.' : `Could not run the checks: ${e.message || e}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function loadOverview() {
|
|
226
|
+
try {
|
|
227
|
+
const o = await api('/overview');
|
|
228
|
+
if (!here() || !o) return;
|
|
229
|
+
overview.value = o;
|
|
230
|
+
checksOk.value = o.checks !== false;
|
|
231
|
+
} catch { /* the health error says enough */ }
|
|
232
|
+
}
|
|
233
|
+
async function loadSignins() {
|
|
234
|
+
try {
|
|
235
|
+
const r = await api(`/signins?days=${encodeURIComponent(days.peek())}`);
|
|
236
|
+
if (here() && r) signins.value = r.entries;
|
|
237
|
+
} catch (e) { E.toast(`Could not load sign-ins: ${e.message || e}`, {type: 'error'}); }
|
|
238
|
+
}
|
|
239
|
+
async function loadBlocks() {
|
|
240
|
+
try {
|
|
241
|
+
const r = await api('/blocks');
|
|
242
|
+
if (here() && r) blocks.value = r;
|
|
243
|
+
} catch { /* shown empty */ }
|
|
244
|
+
}
|
|
245
|
+
async function loadAll(refresh = false) {
|
|
246
|
+
loading.value = true;
|
|
247
|
+
await Promise.all([loadHealth(refresh), loadOverview(), loadBlocks(), tab.peek() === 'signins' ? loadSignins() : null]);
|
|
248
|
+
if (here()) loading.value = false;
|
|
249
|
+
}
|
|
250
|
+
scope(M.effect(() => { void days.value; if (tab.peek() === 'signins') loadSignins(); }));
|
|
251
|
+
|
|
252
|
+
// ---- Actions ----
|
|
253
|
+
async function act(fn, done) {
|
|
254
|
+
try {
|
|
255
|
+
await fn();
|
|
256
|
+
if (done) E.toast(done, {type: 'success'});
|
|
257
|
+
await Promise.all([loadBlocks(), loadOverview()]);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (/403|forbidden/i.test(String(e.message))) { canManage.value = false; E.toast('Only people who may manage Security can change this.', {type: 'warning'}); }
|
|
260
|
+
else E.toast(`Not done: ${e.message || e}`, {type: 'error'});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const unlockKey = (key, label) => act(() => api(`/lockouts?key=${encodeURIComponent(key)}`, {method: 'DELETE'}), `${label} unlocked.`);
|
|
264
|
+
const unblockIp = (ip) => act(() => api(`/blocks?ip=${encodeURIComponent(ip)}`, {method: 'DELETE'}), `${ip} unblocked.`);
|
|
265
|
+
const removeRule = (rule) => act(() => api(`/blocks?rule=${encodeURIComponent(rule)}`, {method: 'DELETE'}), `${rule} may reach the site again.`);
|
|
266
|
+
async function blockRule(rule, note = '', confirmSelf = false) {
|
|
267
|
+
try {
|
|
268
|
+
await api('/blocks', {method: 'POST', body: JSON.stringify({rule, note, confirmSelf})});
|
|
269
|
+
E.toast(`${rule} is blocked.`, {type: 'success'});
|
|
270
|
+
await Promise.all([loadBlocks(), loadOverview()]);
|
|
271
|
+
return true;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
if (e.status === 409 && !confirmSelf) {
|
|
274
|
+
if (await E.confirm(`${esc(e.message)}<br><br>Block it anyway? You would need the command line to get back in.`)) return blockRule(rule, note, true);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
E.toast(`Not blocked: ${e.message || e}`, {type: 'error'});
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function exportCsv() {
|
|
282
|
+
if (!signinList.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
|
|
283
|
+
const rows = signinList.map(e => ({time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
|
|
284
|
+
const url = URL.createObjectURL(new Blob([toCsv(rows)], {type: 'text/csv;charset=utf-8'}));
|
|
285
|
+
const a = Object.assign(document.createElement('a'), {href: url, download: `sign-ins-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`});
|
|
286
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
287
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- Menus ----
|
|
291
|
+
const rowIndex = (row) => Number(String(row?.dataset?.row || '').slice(1));
|
|
292
|
+
function checkItems(row) {
|
|
293
|
+
const c = checkList[rowIndex(row)];
|
|
294
|
+
if (!c) return [];
|
|
295
|
+
const open = openChecks.peek().has(c.id);
|
|
296
|
+
return [
|
|
297
|
+
{header: c.title},
|
|
298
|
+
{label: open ? 'Hide the details' : 'Why, and how to fix it', icon: 'info', action: () => toggleCheck(c)},
|
|
299
|
+
{label: 'Go there', icon: 'external-link', disabled: !c.link, action: () => { location.hash = c.link; }},
|
|
300
|
+
{label: 'Run the checks again', icon: 'refresh-cw', action: () => loadAll(true)},
|
|
301
|
+
{type: 'divider'},
|
|
302
|
+
{label: 'Copy', icon: 'copy', action: () => copy(`${c.title}: ${c.detail}\nWhy: ${c.why}\nFix: ${c.fix}`, 'the check')}
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
function toggleCheck(c) {
|
|
306
|
+
const next = new Set(openChecks.peek());
|
|
307
|
+
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
|
|
308
|
+
openChecks.value = next;
|
|
309
|
+
}
|
|
310
|
+
function signinItems(row) {
|
|
311
|
+
const e = signinList[rowIndex(row)];
|
|
312
|
+
if (!e) return [];
|
|
313
|
+
return [
|
|
314
|
+
{header: `${e.email || 'Unknown'} · ${e.ip || 'no address'}`},
|
|
315
|
+
{label: ipFilter.peek() === e.ip ? 'Show every address' : 'Only this address', icon: 'filter', disabled: !e.ip, action: () => { ipFilter.value = ipFilter.peek() === e.ip ? '' : e.ip; }},
|
|
316
|
+
{label: 'Unlock this account', icon: 'unlock', disabled: !e.email || !canManage.peek(), action: () => unlockKey(`a:${e.email.toLowerCase()}`, e.email)},
|
|
317
|
+
{label: 'Block this address…', icon: 'x-circle', disabled: !e.ip || !canManage.peek(), action: async () => {
|
|
318
|
+
if (await E.confirm(`Block ${esc(e.ip)}? Nothing from it will reach the site.`)) blockRule(e.ip, `From the sign-in log (${e.email || 'unknown'})`);
|
|
319
|
+
}},
|
|
320
|
+
{type: 'divider'},
|
|
321
|
+
{label: 'Copy the address', icon: 'copy', disabled: !e.ip, action: () => copy(e.ip, 'the address')},
|
|
322
|
+
{label: 'Copy the browser', icon: 'clipboard', disabled: !e.ua, action: () => copy(e.ua, 'the browser')}
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
function lockItems(row) {
|
|
326
|
+
const l = blocks.peek().locks[rowIndex(row)];
|
|
327
|
+
if (!l) return [];
|
|
328
|
+
return [{header: l.who}, {label: 'Unlock', icon: 'unlock', disabled: !canManage.peek(), action: () => unlockKey(l.key, l.who)},
|
|
329
|
+
{label: 'Copy', icon: 'copy', action: () => copy(l.who, 'it')}];
|
|
330
|
+
}
|
|
331
|
+
function tempItems(row) {
|
|
332
|
+
const t = blocks.peek().temp[rowIndex(row)];
|
|
333
|
+
if (!t) return [];
|
|
334
|
+
return [{header: t.ip}, {label: 'Unblock', icon: 'unlock', disabled: !canManage.peek(), action: () => unblockIp(t.ip)},
|
|
335
|
+
{label: 'Block for good', icon: 'x-circle', disabled: !canManage.peek(), action: () => blockRule(t.ip, t.reason)},
|
|
336
|
+
{type: 'divider'}, {label: 'Copy the address', icon: 'copy', action: () => copy(t.ip, 'the address')}];
|
|
337
|
+
}
|
|
338
|
+
function ruleItems(row) {
|
|
339
|
+
const r = blocks.peek().rules[rowIndex(row)];
|
|
340
|
+
if (!r) return [];
|
|
341
|
+
return [{header: r.rule}, {label: 'Remove the block', icon: 'trash-2', danger: true, disabled: !canManage.peek(), action: async () => {
|
|
342
|
+
if (await E.confirm(`Let ${esc(r.rule)} reach the site again?`)) removeRule(r.rule);
|
|
343
|
+
}}, {type: 'divider'}, {label: 'Copy', icon: 'copy', action: () => copy(r.rule, 'the rule')}];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function wireList(el, items, main) {
|
|
347
|
+
const menu = E.contextMenu(el, {cursor: 'glyph', match: '[data-row]', inherit: false, density: 'compact', items: (row) => items(row)});
|
|
348
|
+
scope(() => menu?.destroy?.());
|
|
349
|
+
const onClick = (e) => {
|
|
350
|
+
const row = e.target.closest('[data-row]');
|
|
351
|
+
if (!row || e.target.closest('a')) return;
|
|
352
|
+
if (e.target.closest('.scx-kebab')) { k.openMenuAt(row); return; }
|
|
353
|
+
main?.(row);
|
|
354
|
+
};
|
|
355
|
+
const onKey = (e) => {
|
|
356
|
+
const row = e.target.closest('[data-row]');
|
|
357
|
+
if (!row) return;
|
|
358
|
+
if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); k.openMenuAt(row); }
|
|
359
|
+
else if (e.key === 'Enter' && e.target === row) main?.(row);
|
|
360
|
+
};
|
|
361
|
+
el.addEventListener('click', onClick);
|
|
362
|
+
el.addEventListener('keydown', onKey);
|
|
363
|
+
scope(() => { el.removeEventListener('click', onClick); el.removeEventListener('keydown', onKey); });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- Settings (slideover) ----
|
|
367
|
+
let settingsOpen = false;
|
|
368
|
+
async function openSettings() {
|
|
369
|
+
if (settingsOpen) return;
|
|
370
|
+
settingsOpen = true;
|
|
371
|
+
let s;
|
|
372
|
+
try {
|
|
373
|
+
const r = await api('/settings');
|
|
374
|
+
s = r.settings;
|
|
375
|
+
checksOk.value = r.checks !== false;
|
|
376
|
+
} catch (e) {
|
|
377
|
+
settingsOpen = false;
|
|
378
|
+
E.toast(`Could not load the settings: ${e.message || e}`, {type: 'error'});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const f = {
|
|
382
|
+
lockOn: M.observable(s.lockout.enabled), acc: M.observable(String(s.lockout.accountThreshold)), addr: M.observable(String(s.lockout.addressThreshold)),
|
|
383
|
+
win: M.observable(String(s.lockout.windowMin)), base: M.observable(String(s.lockout.baseMin)), max: M.observable(String(s.lockout.maxMin)),
|
|
384
|
+
polOn: M.observable(s.policy.enabled), minLen: M.observable(String(s.policy.minLength)), common: M.observable(s.policy.blockCommon), personal: M.observable(s.policy.blockPersonal),
|
|
385
|
+
probeOn: M.observable(s.probes.enabled), after: M.observable(String(s.probes.blockAfter)), within: M.observable(String(s.probes.withinMin)), forMin: M.observable(String(s.probes.blockForMin)),
|
|
386
|
+
extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
|
|
387
|
+
signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
|
|
388
|
+
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks)
|
|
389
|
+
};
|
|
390
|
+
const body = () => ({
|
|
391
|
+
lockout: {enabled: f.lockOn.value, accountThreshold: f.acc.value, addressThreshold: f.addr.value, windowMin: f.win.value, baseMin: f.base.value, maxMin: f.max.value},
|
|
392
|
+
policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
|
|
393
|
+
probes: {enabled: f.probeOn.value, blockAfter: f.after.value, withinMin: f.within.value, blockForMin: f.forMin.value, extra: f.extra.value, allow: f.allow.value},
|
|
394
|
+
signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
|
|
395
|
+
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value}
|
|
396
|
+
});
|
|
397
|
+
const opened = JSON.stringify(body());
|
|
398
|
+
const saving = M.observable(false);
|
|
399
|
+
const checked = M.computed(() => checkSettings(body(), s));
|
|
400
|
+
const errText = M.computed(() => Object.values(checked.value.errors).join(' '));
|
|
401
|
+
const model = {...f, saving, errText, cannot: M.computed(() => saving.value || Boolean(errText.value)), checksOk};
|
|
402
|
+
const num = (id, key, label, help, min, max) => `<div><label class="form-label" for="scx-s-${id}">${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>
|
|
403
|
+
<input id="scx-s-${id}" type="number" min="${min}" max="${max}" class="form-input" data-model="${key}.value"></div>`;
|
|
404
|
+
const sw = (key, label, help = '') => `<label class="scx-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${key}.value"> ${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>`;
|
|
405
|
+
const so = E.slideover({title: 'Security settings', size: 'md', position: 'right'});
|
|
406
|
+
const panel = fragment(`
|
|
407
|
+
<div class="scx-form">
|
|
408
|
+
<p class="scx-note is-warn" data-bind-hidden="checksOk.value">This version of Domma CMS cannot run sign-in checks: the lockout and password rules below take effect once it is updated (0.83 or later).</p>
|
|
409
|
+
<section><h4>Sign-in lockout</h4><div class="scx-form-grid">
|
|
410
|
+
${sw('lockOn', 'Lock after repeated wrong passwords', 'Locks the account and the address for a while - longer each time. The right password does not get in while it is locked, so guessing stops paying.')}
|
|
411
|
+
${num('acc', 'acc', 'Wrong passwords on one account', 'How many, within the time below, lock the account.', 3, 50)}
|
|
412
|
+
${num('addr', 'addr', 'Wrong passwords from one address', 'Higher than for an account: a whole office can share one address.', 5, 500)}
|
|
413
|
+
${num('win', 'win', 'Counted over (minutes)', '', 1, 1440)}
|
|
414
|
+
${num('base', 'base', 'First lock (minutes)', 'Each lock after that is twice as long.', 1, 60)}
|
|
415
|
+
${num('max', 'max', 'Longest lock (minutes)', '', 1, 10080)}
|
|
416
|
+
</div></section>
|
|
417
|
+
<section><h4>Password rules</h4><div class="scx-form-grid">
|
|
418
|
+
${sw('polOn', 'Check new passwords', 'Applies when a password is set or changed - accounts keep the one they have until then.')}
|
|
419
|
+
${num('minlen', 'minLen', 'Shortest password', 'Length matters most. 10 or more is sensible; 12+ better.', 8, 64)}
|
|
420
|
+
${sw('common', 'Refuse the 10,000 most common passwords')}
|
|
421
|
+
${sw('personal', 'Refuse passwords containing the person\'s name or email')}
|
|
422
|
+
</div></section>
|
|
423
|
+
<section><h4>Scanners</h4><div class="scx-form-grid">
|
|
424
|
+
${sw('probeOn', 'Refuse probes, and block addresses that keep probing', 'Requests for things no Domma site has (WordPress, .env files, database dumps) get an empty not found. An address someone signed in from in the last week is never blocked.')}
|
|
425
|
+
${num('after', 'after', 'Probes before a block', '', 1, 100)}
|
|
426
|
+
${num('within', 'within', 'Counted over (minutes)', '', 1, 1440)}
|
|
427
|
+
${num('formin', 'forMin', 'Block for (minutes)', '', 1, 10080)}
|
|
428
|
+
<div class="scx-wide"><label class="form-label" for="scx-s-extra">Also treat as probes <span data-help="One path per line, starting with /. A trailing / covers everything under it."></span></label>
|
|
429
|
+
<textarea id="scx-s-extra" rows="2" class="form-input scx-mono" data-model="extra.value" placeholder="/old-admin/"></textarea></div>
|
|
430
|
+
<div class="scx-wide"><label class="form-label" for="scx-s-allow">This site does serve <span data-help="Paths that look like probes but are real here - for example /legacy/ if you host old .php pages. One per line."></span></label>
|
|
431
|
+
<textarea id="scx-s-allow" rows="2" class="form-input scx-mono" data-model="allow.value"></textarea></div>
|
|
432
|
+
</div></section>
|
|
433
|
+
<section><h4>Keeping and telling</h4><div class="scx-form-grid">
|
|
434
|
+
${num('days', 'signinDays', 'Keep the sign-in log (days)', '', 7, 365)}
|
|
435
|
+
${num('stale', 'staleDays', 'Admin unused after (days)', 'The health check flags admins who have not signed in for this long.', 14, 730)}
|
|
436
|
+
${sw('nLock', 'Notify when an account or address is locked')}
|
|
437
|
+
${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
|
|
438
|
+
${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
|
|
439
|
+
</div></section>
|
|
440
|
+
<section><h4>Locked out yourself?</h4>
|
|
441
|
+
<p class="scx-note">Run this in the site's folder on the server - it clears lockouts and blocks, and the site notices within seconds:</p>
|
|
442
|
+
<div class="scx-code">node plugins/security/bin/unlock.js --list\nnode plugins/security/bin/unlock.js --all</div>
|
|
443
|
+
</section>
|
|
444
|
+
<p class="scx-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
445
|
+
<div class="scx-actions">
|
|
446
|
+
<small class="scx-muted">Ctrl+Enter saves.</small><span class="scx-spacer"></span>
|
|
447
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
448
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save</button>
|
|
449
|
+
</div>
|
|
450
|
+
</div>`);
|
|
451
|
+
async function save() {
|
|
452
|
+
if (model.cannot.peek()) return;
|
|
453
|
+
saving.value = true;
|
|
454
|
+
try {
|
|
455
|
+
const r = await api('/settings', {method: 'PUT', body: JSON.stringify(checked.peek().settings)});
|
|
456
|
+
settings.value = r.settings;
|
|
457
|
+
E.toast('Settings saved.', {type: 'success'});
|
|
458
|
+
so.close(true);
|
|
459
|
+
loadAll(true);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
E.toast(`Not saved: ${e.message || e}`, {type: 'error'});
|
|
462
|
+
} finally {
|
|
463
|
+
saving.value = false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
so.element.appendChild(panel);
|
|
467
|
+
const disposeHelp = k.helpPopovers(panel);
|
|
468
|
+
const handle = M.applyBindings(model, panel, {methods: {save, cancel: () => so.close()}});
|
|
469
|
+
panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
|
|
470
|
+
k.onceClosed(so, () => { handle.dispose(); disposeHelp(); settingsOpen = false; });
|
|
471
|
+
const close = so.close.bind(so);
|
|
472
|
+
let asking = false;
|
|
473
|
+
so.close = async (force) => {
|
|
474
|
+
if (force !== true && JSON.stringify(body()) !== opened) {
|
|
475
|
+
if (asking) return undefined;
|
|
476
|
+
asking = true;
|
|
477
|
+
const ok = await E.confirm('Discard the changes you have not saved?');
|
|
478
|
+
asking = false;
|
|
479
|
+
if (!ok) return undefined;
|
|
480
|
+
}
|
|
481
|
+
return close();
|
|
482
|
+
};
|
|
483
|
+
scope(() => { try { so.close(true); } catch { /* gone */ } });
|
|
484
|
+
I.scan(panel);
|
|
485
|
+
so.open();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- Bindings ----
|
|
489
|
+
root.querySelector('.scx-checks').innerHTML = CHECK_ROW;
|
|
490
|
+
root.querySelector('.scx-signins').innerHTML = SIGNIN_ROW;
|
|
491
|
+
for (const sel of ['.scx-locks', '.scx-temps', '.scx-rules']) root.querySelector(sel).innerHTML = PLAIN_ROW;
|
|
492
|
+
const setTab = (t) => {
|
|
493
|
+
if (tab.peek() === t) return;
|
|
494
|
+
tab.value = t;
|
|
495
|
+
history.replaceState(null, '', `#/plugins/security${t === 'overview' ? '' : `/${t}`}`);
|
|
496
|
+
if (t === 'signins') loadSignins();
|
|
497
|
+
if (t === 'blocked') loadBlocks();
|
|
498
|
+
};
|
|
499
|
+
const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
|
|
500
|
+
result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText};
|
|
501
|
+
const methods = {
|
|
502
|
+
tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
|
|
503
|
+
res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
|
|
504
|
+
exportCsv,
|
|
505
|
+
unlockAll: async () => { if (await E.confirm('Unlock every locked account and address?')) act(() => api('/lockouts?key=*', {method: 'DELETE'}), 'Everything unlocked.'); },
|
|
506
|
+
addRule: () => {
|
|
507
|
+
const rule = newRule.peek().trim();
|
|
508
|
+
if (!ipRuleValid(rule)) { E.toast('Not an address or range - for example 81.2.69.142, 192.168.1.* or 10.0.0.0/8.', {type: 'warning'}); return false; }
|
|
509
|
+
blockRule(rule, newNote.peek().trim()).then((ok) => { if (ok) { newRule.value = ''; newNote.value = ''; } });
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const bindings = M.applyBindings(model, root, {methods});
|
|
514
|
+
scope(() => bindings.dispose());
|
|
515
|
+
scope(k.helpPopovers(root, {position: 'bottom'}));
|
|
516
|
+
wireList(root.querySelector('.scx-checks'), checkItems, (row) => { const c = checkList[rowIndex(row)]; if (c) toggleCheck(c); });
|
|
517
|
+
wireList(root.querySelector('.scx-signins'), signinItems);
|
|
518
|
+
wireList(root.querySelector('.scx-locks'), lockItems);
|
|
519
|
+
wireList(root.querySelector('.scx-temps'), tempItems);
|
|
520
|
+
wireList(root.querySelector('.scx-rules'), ruleItems);
|
|
521
|
+
|
|
522
|
+
k.addBannerAction($container, {id: 'security-refresh', icon: 'refresh-cw', title: 'Run the checks again', onClick: () => loadAll(true)});
|
|
523
|
+
k.addBannerAction($container, {id: 'security-settings', icon: 'settings', title: 'Security settings', onClick: openSettings});
|
|
524
|
+
|
|
525
|
+
I.scan(root);
|
|
526
|
+
await loadAll(false);
|
|
527
|
+
if (here()) I.scan(root);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Security - emergency unlock. Run in the SITE's directory (where config/ and
|
|
4
|
+
* plugins/ are); the running site picks the change up within a few seconds.
|
|
5
|
+
*
|
|
6
|
+
* node plugins/security/bin/unlock.js --list what is locked or blocked now
|
|
7
|
+
* node plugins/security/bin/unlock.js --all every lockout and block, gone
|
|
8
|
+
* node plugins/security/bin/unlock.js --ip 81.2.69.142 that address: lockout, temporary block, exact rule
|
|
9
|
+
* node plugins/security/bin/unlock.js --user sam@x.com that account's lockout
|
|
10
|
+
*
|
|
11
|
+
* (Security Pro's copy also takes --2fa sam@x.com, to switch off a user's two-factor.)
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import {fileURLToPath} from 'node:url';
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const file = path.join(here, '..', 'data', 'state.json');
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const flag = (name) => {
|
|
21
|
+
const i = args.indexOf(name);
|
|
22
|
+
return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let state;
|
|
26
|
+
try {
|
|
27
|
+
state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
console.log('Nothing is locked or blocked (no state file yet).');
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
state.lockouts ??= {};
|
|
33
|
+
state.rules ??= [];
|
|
34
|
+
state.temp ??= {};
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
function list() {
|
|
38
|
+
const locks = Object.entries(state.lockouts).filter(([, e]) => e.until > now);
|
|
39
|
+
const temp = Object.entries(state.temp).filter(([, t]) => t.until > now);
|
|
40
|
+
console.log(`Lockouts (${locks.length}):`);
|
|
41
|
+
for (const [k, e] of locks) console.log(` ${k.startsWith('a:') ? 'account' : 'address'} ${k.slice(2)} until ${new Date(e.until).toLocaleString('en-GB')}`);
|
|
42
|
+
console.log(`Temporary blocks (${temp.length}):`);
|
|
43
|
+
for (const [ip, t] of temp) console.log(` ${ip} until ${new Date(t.until).toLocaleString('en-GB')} - ${t.reason}`);
|
|
44
|
+
console.log(`Rules (${state.rules.length}):`);
|
|
45
|
+
for (const r of state.rules) console.log(` ${r.rule}${r.note ? ` - ${r.note}` : ''}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (flag('--list') || !args.length) {
|
|
49
|
+
list();
|
|
50
|
+
if (!args.length) console.log('\nUse --all, --ip <address> or --user <email> to unlock.');
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const changes = [];
|
|
55
|
+
if (flag('--all')) {
|
|
56
|
+
changes.push(`${Object.keys(state.lockouts).length} lockouts and ${Object.keys(state.temp).length} temporary blocks cleared`);
|
|
57
|
+
state.lockouts = {};
|
|
58
|
+
state.temp = {};
|
|
59
|
+
}
|
|
60
|
+
const ip = flag('--ip');
|
|
61
|
+
if (typeof ip === 'string') {
|
|
62
|
+
const a = ip.replace(/^::ffff:/i, '').toLowerCase();
|
|
63
|
+
delete state.lockouts[`i:${a}`];
|
|
64
|
+
delete state.temp[a];
|
|
65
|
+
const before = state.rules.length;
|
|
66
|
+
state.rules = state.rules.filter(r => r.rule.toLowerCase() !== a);
|
|
67
|
+
changes.push(`${a}: lockout and temporary block cleared${before !== state.rules.length ? ', its rule removed' : ''}`);
|
|
68
|
+
}
|
|
69
|
+
const user = flag('--user');
|
|
70
|
+
if (typeof user === 'string') {
|
|
71
|
+
delete state.lockouts[`a:${user.trim().toLowerCase()}`];
|
|
72
|
+
changes.push(`${user}: lockout cleared`);
|
|
73
|
+
}
|
|
74
|
+
if (!changes.length) {
|
|
75
|
+
console.log('Nothing to do. Use --list, --all, --ip <address> or --user <email>.');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(state));
|
|
79
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
80
|
+
for (const c of changes) console.log(`✓ ${c}`);
|
|
81
|
+
console.log('The site picks this up within a few seconds.');
|