domma-cms 0.87.0 → 0.88.1
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/CLAUDE.md +9 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/http-interceptor.js +1 -1
- package/admin/js/lib/login-arrange.js +1 -0
- package/admin/js/lib/password-field.js +1 -0
- package/admin/js/lib/password-reset-actions.js +12 -0
- package/admin/js/lib/plugins-arrange.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -1
- package/admin/js/lib/users-arrange.js +1 -1
- package/admin/js/templates/login.html +94 -44
- package/admin/js/templates/plugins.html +7 -0
- package/admin/js/templates/settings.html +6 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/views/login.js +1 -11
- package/admin/js/views/plugins.js +17 -7
- package/admin/js/views/settings.js +4 -4
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -2
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +13 -12
- package/plugins/mail-reader/CLAUDE.md +5 -0
- package/plugins/mail-reader/plugin.json +5 -2
- package/plugins/security/CLAUDE.md +7 -0
- package/plugins/security/admin/lib/resets.js +82 -0
- package/plugins/security/admin/lib/settings.js +1 -1
- package/plugins/security/admin/templates/security.html +3 -0
- package/plugins/security/admin/views/security.js +23 -10
- package/plugins/security/plugin.js +39 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/security.test.js +34 -0
- package/server/routes/api/auth.js +41 -63
- package/server/routes/api/forms.js +3 -0
- package/server/routes/api/plugins.js +33 -3
- package/server/routes/api/related.js +6 -3
- package/server/routes/api/settings.js +12 -0
- package/server/routes/api/tools.js +116 -0
- package/server/routes/api/users.js +49 -2
- package/server/server.js +27 -8
- package/server/services/notification-sources.js +21 -1
- package/server/services/passwordReset.js +507 -0
- package/server/services/plugins.js +27 -1
- package/server/services/renderer.js +5 -2
- package/server/services/siteGitignore.js +114 -3
- package/server/services/tools.js +259 -0
- package/server/services/users.js +8 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - password reset requests (core's auth:resetRequested, 0.88). Pure.
|
|
3
|
+
*
|
|
4
|
+
* Each "Forgot your password?" goes into the sign-in log as `kind: 'reset'`.
|
|
5
|
+
* One address asking for resets of many different accounts is someone trying
|
|
6
|
+
* addresses, or trying to fill inboxes - that is what `recordReset` watches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What became of a request, as the core reports it in `reason`. */
|
|
10
|
+
export const RESET_REASONS = {
|
|
11
|
+
sent: 'Link sent',
|
|
12
|
+
unknown: 'No such account',
|
|
13
|
+
inactive: 'Account switched off',
|
|
14
|
+
throttled: 'Held back - 3 an hour already',
|
|
15
|
+
'no-smtp': 'Not sent - no mail server',
|
|
16
|
+
'no-origin': 'Not sent - no Site URL',
|
|
17
|
+
failed: 'Email failed'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* When it is worth telling someone. The core lets one address ask 3 times an hour
|
|
22
|
+
* (a route rate limit), and a person only ever asks about their own account - so
|
|
23
|
+
* one address using all 3 on different accounts is trying addresses. Someone
|
|
24
|
+
* rotating addresses gets past that, which is what the site-wide count of
|
|
25
|
+
* requests for accounts that do not exist is for.
|
|
26
|
+
*/
|
|
27
|
+
export const RESET_PROBE = {threshold: 3, windowMin: 60};
|
|
28
|
+
export const RESET_UNKNOWN = {threshold: 10, windowMin: 60};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Note a reset request from an address and say whether it has just crossed the
|
|
32
|
+
* line: `threshold` different accounts within `windowMin`. Mutates `recent`
|
|
33
|
+
* (a Map of address -> [{at, email}]), dropping what is older than the window.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{accounts: number, tripped: boolean}} tripped only on the request that reaches the threshold
|
|
36
|
+
*/
|
|
37
|
+
export function recordReset(recent, ip, email, {now = Date.now(), threshold = RESET_PROBE.threshold, windowMin = RESET_PROBE.windowMin} = {}) {
|
|
38
|
+
if (!ip) return {accounts: 0, tripped: false};
|
|
39
|
+
const since = now - windowMin * 60_000;
|
|
40
|
+
const list = (recent.get(ip) || []).filter(r => r.at >= since);
|
|
41
|
+
const before = new Set(list.map(r => r.email)).size;
|
|
42
|
+
list.push({at: now, email: String(email || '').trim().toLowerCase()});
|
|
43
|
+
recent.set(ip, list);
|
|
44
|
+
const accounts = new Set(list.map(r => r.email)).size;
|
|
45
|
+
return {accounts, tripped: accounts >= threshold && before < threshold};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Note a request for an account that does not exist, from any address, and say
|
|
50
|
+
* whether the site has just reached `threshold` of them within `windowMin`.
|
|
51
|
+
* Mutates `stamps` (an array of times).
|
|
52
|
+
*
|
|
53
|
+
* @returns {{count: number, tripped: boolean}} tripped only on the request that reaches the threshold
|
|
54
|
+
*/
|
|
55
|
+
export function recordUnknown(stamps, {now = Date.now(), threshold = RESET_UNKNOWN.threshold, windowMin = RESET_UNKNOWN.windowMin} = {}) {
|
|
56
|
+
const since = now - windowMin * 60_000;
|
|
57
|
+
const keep = stamps.filter(t => t >= since);
|
|
58
|
+
stamps.length = 0;
|
|
59
|
+
stamps.push(...keep, now);
|
|
60
|
+
return {count: stamps.length, tripped: stamps.length === threshold};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Forget site-wide stamps older than the window. Mutates `stamps`. */
|
|
64
|
+
export function pruneUnknown(stamps, {now = Date.now(), windowMin = RESET_UNKNOWN.windowMin} = {}) {
|
|
65
|
+
const keep = stamps.filter(t => t >= now - windowMin * 60_000);
|
|
66
|
+
stamps.length = 0;
|
|
67
|
+
stamps.push(...keep);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget addresses with nothing inside the window. */
|
|
71
|
+
export function pruneResets(recent, {now = Date.now(), windowMin = RESET_PROBE.windowMin} = {}) {
|
|
72
|
+
const since = now - windowMin * 60_000;
|
|
73
|
+
for (const [ip, list] of recent) {
|
|
74
|
+
const keep = list.filter(r => r.at >= since);
|
|
75
|
+
if (keep.length) recent.set(ip, keep); else recent.delete(ip);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One log entry as the Sign-ins list shows it. */
|
|
80
|
+
export function resetRowText(e) {
|
|
81
|
+
return {okText: 'Reset asked', tone: 'reset', note: RESET_REASONS[e.reason] || e.reason || (e.ok ? 'Link sent' : 'Not sent')};
|
|
82
|
+
}
|
|
@@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
11
11
|
probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
|
|
12
12
|
signinDays: 30,
|
|
13
13
|
staleAdminDays: 90,
|
|
14
|
-
notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
|
|
14
|
+
notify: {lockouts: true, successAfterFails: true, probeBlocks: false, resetProbing: true}
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
const int = (v, min, max) => {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<div class="scx-card scx-tile"><small>Failed sign-ins, 24h <span data-help="Failed sign-ins in the last 24 hours: a wrong password, an unknown email, a locked account."></span></small><strong data-bind-text="ov.value.failed">—</strong></div>
|
|
23
23
|
<div class="scx-card scx-tile"><small>Blocked now <span data-help="Addresses blocked by a rule or for probing, and accounts or addresses locked after wrong passwords."></span></small><strong data-bind-text="ov.value.blocked">—</strong></div>
|
|
24
24
|
<div class="scx-card scx-tile"><small>Probes refused today <span data-help="Requests for things no Domma site has - WordPress logins, .env files, database dumps. Scanners send them to every site they find; they are answered with an empty 'not found'."></span></small><strong data-bind-text="ov.value.probes">—</strong></div>
|
|
25
|
+
<div class="scx-card scx-tile"><small>Reset requests, 24h <span data-help=""Forgot your password?" requests in the last 24 hours, sent or not. Sign-ins › Resets lists them, with what happened and where from."></span></small><strong data-bind-text="ov.value.resets">—</strong></div>
|
|
25
26
|
</div>
|
|
26
27
|
|
|
27
28
|
<div class="scx-card">
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
<button type="button" data-on-click="res_all" data-bind-class="result.value === 'all' && 'is-on'">All</button>
|
|
42
43
|
<button type="button" data-on-click="res_failed" data-bind-class="result.value === 'failed' && 'is-on'">Failed</button>
|
|
43
44
|
<button type="button" data-on-click="res_ok" data-bind-class="result.value === 'ok' && 'is-on'">Succeeded</button>
|
|
45
|
+
<button type="button" data-on-click="res_resets" data-bind-class="result.value === 'resets' && 'is-on'">Resets</button>
|
|
44
46
|
</div>
|
|
45
47
|
<select class="form-input scx-days" aria-label="Period" data-model="days.value">
|
|
46
48
|
<option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="365">All kept</option>
|
|
@@ -150,6 +152,7 @@
|
|
|
150
152
|
.scx-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
153
|
.scx-pill { display: inline-flex; align-items: center; justify-self: start; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .5rem; border-radius: 999px; }
|
|
152
154
|
.scx-pill[data-ok="true"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
|
|
155
|
+
.scx-pill[data-ok="reset"] { color: var(--dm-info); background: color-mix(in srgb, var(--dm-info) 14%, transparent); }
|
|
153
156
|
.scx-pill[data-ok="false"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
|
|
154
157
|
.scx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .25rem; }
|
|
155
158
|
.scx-kebab:hover { color: var(--dm-primary); }
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* only, and rows are addressed by index.
|
|
11
11
|
* `#/plugins/security/<tab>` opens a tab. Pure logic: ../lib/*.js.
|
|
12
12
|
*/
|
|
13
|
+
import {resetRowText} from '../lib/resets.js';
|
|
13
14
|
import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
|
|
14
15
|
import {ipRuleValid} from '../lib/ip.js';
|
|
15
16
|
|
|
@@ -152,7 +153,8 @@ export const securityView = {
|
|
|
152
153
|
const asOf = M.computed(() => (health.value?.at ? `Checked ${new Date(health.value.at).toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'})}` : ''));
|
|
153
154
|
const ov = M.computed(() => {
|
|
154
155
|
const o = overview.value;
|
|
155
|
-
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0)
|
|
156
|
+
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0), resets: String(o.resets24h ?? 0)}
|
|
157
|
+
: {failed: '—', blocked: '—', probes: '—', resets: '—'};
|
|
156
158
|
});
|
|
157
159
|
const blockedCount = M.computed(() => { const b = blocks.value; const n = b.rules.length + b.temp.length + b.locks.length; return n ? String(n) : ''; });
|
|
158
160
|
|
|
@@ -185,18 +187,25 @@ export const securityView = {
|
|
|
185
187
|
const q = query.value.trim().toLowerCase();
|
|
186
188
|
const r = result.value;
|
|
187
189
|
const only = ipFilter.value;
|
|
188
|
-
|
|
190
|
+
// A reset request is in the log too (kind 'reset'): only under All and Resets.
|
|
191
|
+
const isReset = (e) => e.kind === 'reset';
|
|
192
|
+
signinList = signins.value.filter(e => (r === 'all' || (r === 'resets' ? isReset(e) : !isReset(e) && (r === 'ok') === Boolean(e.ok)))
|
|
189
193
|
&& (!only || e.ip === only)
|
|
190
194
|
&& (!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),
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
signinRows.value = signinList.slice(0, 1000).map((e, i) => {
|
|
196
|
+
const row = {id: `s${i}`, when: when(e.at), email: e.email || '—', ip: e.ip || '—', ua: e.ua || ''};
|
|
197
|
+
if (isReset(e)) {
|
|
198
|
+
const t = resetRowText(e);
|
|
199
|
+
return {...row, ok: t.tone, okText: t.okText, note: `${t.note} · ${browserOf(e.ua)}`};
|
|
200
|
+
}
|
|
201
|
+
return {...row, ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
|
|
202
|
+
note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`};
|
|
203
|
+
});
|
|
196
204
|
}));
|
|
197
205
|
const signinText = M.computed(() => {
|
|
198
206
|
const n = signinList.length + 0 * signinRows.value.length;
|
|
199
207
|
const f = ipFilter.value ? ` from ${ipFilter.value}` : '';
|
|
208
|
+
if (result.value === 'resets') return `${n.toLocaleString('en-GB')} reset request${n === 1 ? '' : 's'}${f}`;
|
|
200
209
|
return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
|
|
201
210
|
});
|
|
202
211
|
const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
|
|
@@ -280,7 +289,9 @@ export const securityView = {
|
|
|
280
289
|
}
|
|
281
290
|
function exportCsv() {
|
|
282
291
|
if (!signinList.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
|
|
283
|
-
const rows = signinList.map(e => (
|
|
292
|
+
const rows = signinList.map(e => (e.kind === 'reset'
|
|
293
|
+
? {time: e.at, result: 'reset requested', email: e.email, address: e.ip, reason: resetRowText(e).note, browser: e.ua}
|
|
294
|
+
: {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
295
|
const url = URL.createObjectURL(new Blob([toCsv(rows)], {type: 'text/csv;charset=utf-8'}));
|
|
285
296
|
const a = Object.assign(document.createElement('a'), {href: url, download: `sign-ins-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`});
|
|
286
297
|
document.body.appendChild(a); a.click(); a.remove();
|
|
@@ -385,14 +396,14 @@ export const securityView = {
|
|
|
385
396
|
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
397
|
extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
|
|
387
398
|
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)
|
|
399
|
+
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
|
|
389
400
|
};
|
|
390
401
|
const body = () => ({
|
|
391
402
|
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
403
|
policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
|
|
393
404
|
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
405
|
signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
|
|
395
|
-
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value}
|
|
406
|
+
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
|
|
396
407
|
});
|
|
397
408
|
const opened = JSON.stringify(body());
|
|
398
409
|
const saving = M.observable(false);
|
|
@@ -436,6 +447,7 @@ export const securityView = {
|
|
|
436
447
|
${sw('nLock', 'Notify when an account or address is locked')}
|
|
437
448
|
${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
|
|
438
449
|
${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
|
|
450
|
+
${sw('nReset', 'Notify when password resets look like someone trying addresses', 'One address asking about 3 different accounts within an hour (a person only asks about their own), or 10 requests in an hour for accounts that do not exist, from anywhere. Every request is in the sign-in log either way.')}
|
|
439
451
|
</div></section>
|
|
440
452
|
<section><h4>Locked out yourself?</h4>
|
|
441
453
|
<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>
|
|
@@ -499,6 +511,7 @@ export const securityView = {
|
|
|
499
511
|
const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
|
|
500
512
|
result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText};
|
|
501
513
|
const methods = {
|
|
514
|
+
res_resets: () => { result.value = 'resets'; },
|
|
502
515
|
tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
|
|
503
516
|
res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
|
|
504
517
|
exportCsv,
|
|
@@ -31,6 +31,7 @@ import {checkSettings, mergeDefaults} from './admin/lib/settings.js';
|
|
|
31
31
|
import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from './admin/lib/lockout.js';
|
|
32
32
|
import {passwordProblems} from './admin/lib/policy.js';
|
|
33
33
|
import {isProbe} from './admin/lib/probes.js';
|
|
34
|
+
import {pruneResets, pruneUnknown, recordReset, recordUnknown} from './admin/lib/resets.js';
|
|
34
35
|
import {blockedBy, isTrusted, pruneBlocks, recordProbe} from './admin/lib/blocks.js';
|
|
35
36
|
import {ipRuleValid, isLoopbackIp, plainIp} from './admin/lib/ip.js';
|
|
36
37
|
import {getConfig, saveConfig} from '../../server/config.js';
|
|
@@ -72,6 +73,7 @@ async function security(fastify, options = {}) {
|
|
|
72
73
|
const notify = (p) => { try { hooks.notify?.(p); } catch { /* notifications are a courtesy */ } };
|
|
73
74
|
hooks.registerNotificationSource?.({id: 'lockouts', label: 'Sign-in lockouts', description: 'An account or address was locked after repeated wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
74
75
|
hooks.registerNotificationSource?.({id: 'suspicious', label: 'Suspicious sign-ins', description: 'Someone signed in after many wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
76
|
+
hooks.registerNotificationSource?.({id: 'resets', label: 'Password reset probing', description: 'One address asked for password resets of many different accounts.', severity: 'warning', audience: 'admins'});
|
|
75
77
|
hooks.registerNotificationSource?.({id: 'probes', label: 'Scanners blocked', description: 'An address probing for weaknesses was blocked.', severity: 'info', audience: 'admins', defaultOn: false});
|
|
76
78
|
|
|
77
79
|
// ---------------------------------------------------------------------
|
|
@@ -161,6 +163,38 @@ async function security(fastify, options = {}) {
|
|
|
161
163
|
}
|
|
162
164
|
});
|
|
163
165
|
|
|
166
|
+
// Password reset requests (core 0.88's auth:resetRequested): into the
|
|
167
|
+
// sign-in log, and a notice when one address asks about many accounts.
|
|
168
|
+
const recentResets = new Map();
|
|
169
|
+
const unknownResets = [];
|
|
170
|
+
hooks.on?.('auth:resetRequested', (e) => {
|
|
171
|
+
try {
|
|
172
|
+
const ip = plainIp(e.ip);
|
|
173
|
+
state.logSignin({at: new Date().toISOString(), kind: 'reset', ok: Boolean(e.sent), email: e.email || '', userId: e.userId || null,
|
|
174
|
+
ip, ua: e.ua || '', reason: e.reason || ''});
|
|
175
|
+
if (isLoopbackIp(ip)) return;
|
|
176
|
+
if (e.reason === 'unknown') {
|
|
177
|
+
const many = recordUnknown(unknownResets);
|
|
178
|
+
if (many.tripped && cfg.notify.resetProbing !== false) {
|
|
179
|
+
notify({source: 'resets', severity: 'warning', title: `${many.count} password resets for accounts that do not exist`,
|
|
180
|
+
dedupeKey: 'reset-unknown',
|
|
181
|
+
body: `Within the hour, ${many.count} "Forgot your password?" requests named accounts this site does not have, from ${new Set(state.readSignins(Date.now() - 3_600_000).filter(x => x.kind === 'reset' && x.reason === 'unknown').map(x => x.ip)).size || 'several'} addresses - someone trying email addresses. The sign-in log (Resets) shows which.`,
|
|
182
|
+
link: '#/plugins/security/signins'});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!ip) return;
|
|
186
|
+
const {accounts, tripped} = recordReset(recentResets, ip, e.email);
|
|
187
|
+
if (tripped && cfg.notify.resetProbing !== false) {
|
|
188
|
+
notify({source: 'resets', severity: 'warning', title: `Password resets asked for ${accounts} accounts from ${ip}`,
|
|
189
|
+
dedupeKey: `reset-probe:${ip}`,
|
|
190
|
+
body: `${ip} asked for password resets of ${accounts} different accounts within the hour - a person only ever asks about their own, so this is someone trying email addresses. The log shows which; block the address if it is not someone you know.`,
|
|
191
|
+
link: '#/plugins/security/signins'});
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.warn(`[security] reset request not recorded: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
164
198
|
hooks.on?.('auth:loginSucceeded', (e) => {
|
|
165
199
|
try {
|
|
166
200
|
const ip = plainIp(e.ip);
|
|
@@ -217,12 +251,14 @@ async function security(fastify, options = {}) {
|
|
|
217
251
|
routes.get('/overview', may('read'), async () => {
|
|
218
252
|
const s = state.get();
|
|
219
253
|
const now = Date.now();
|
|
220
|
-
const
|
|
254
|
+
const all = state.readSignins(now - 86_400_000);
|
|
255
|
+
const day = all.filter(e => e.kind !== 'reset');
|
|
221
256
|
const today = s.counters.day === new Date().toISOString().slice(0, 10) ? s.counters : {probes: 0, blocks: 0, failed: 0};
|
|
222
257
|
return {
|
|
223
258
|
today,
|
|
224
259
|
failed24h: day.filter(e => !e.ok).length,
|
|
225
260
|
succeeded24h: day.filter(e => e.ok).length,
|
|
261
|
+
resets24h: all.length - day.length,
|
|
226
262
|
locks: activeLocks(s.lockouts, now).length,
|
|
227
263
|
blocked: Object.values(s.temp).filter(t => t.until > now).length + s.rules.length,
|
|
228
264
|
checks: canCheck,
|
|
@@ -302,6 +338,8 @@ async function security(fastify, options = {}) {
|
|
|
302
338
|
// ---------------------------------------------------------------------
|
|
303
339
|
const tidy = () => {
|
|
304
340
|
state.change((s) => { pruneLockouts(s.lockouts, {windowMin: cfg.lockout.windowMin}); pruneBlocks(s); });
|
|
341
|
+
pruneResets(recentResets);
|
|
342
|
+
pruneUnknown(unknownResets);
|
|
305
343
|
state.pruneSignins(cfg.signinDays);
|
|
306
344
|
};
|
|
307
345
|
const daily = setInterval(tidy, 6 * 3_600_000);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "security",
|
|
3
3
|
"displayName": "Security",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-09-
|
|
8
|
+
"date": "2026-09-27",
|
|
9
9
|
"icon": "shield",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -132,3 +132,37 @@ test('health: facts become checks; unknown is info, never a pass; the score coun
|
|
|
132
132
|
assert.equal(unknown.checks.find(c => c.id === 'certificate').status, 'info');
|
|
133
133
|
assert.equal(unknown.checks.find(c => c.id === 'headers').status, 'info');
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
test('resets: one address asking about 3 different accounts in an hour trips once', async () => {
|
|
137
|
+
const {recordReset, pruneResets, resetRowText} = await import('../admin/lib/resets.js');
|
|
138
|
+
const recent = new Map();
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const at = (m) => ({now: now + m * MIN});
|
|
141
|
+
assert.deepEqual(recordReset(recent, '1.2.3.4', 'a@x.test', at(0)), {accounts: 1, tripped: false});
|
|
142
|
+
// The same account again is not another account.
|
|
143
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'A@x.test', at(1)).accounts, 1);
|
|
144
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'b@x.test', at(2)).tripped, false);
|
|
145
|
+
assert.deepEqual(recordReset(recent, '1.2.3.4', 'c@x.test', at(3)), {accounts: 3, tripped: true});
|
|
146
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'd@x.test', at(4)).tripped, false, 'only on crossing');
|
|
147
|
+
// Another address is counted on its own; an hour later the first starts again.
|
|
148
|
+
assert.equal(recordReset(recent, '5.6.7.8', 'a@x.test', at(5)).accounts, 1);
|
|
149
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'z@x.test', at(70)).accounts, 1);
|
|
150
|
+
pruneResets(recent, {now: now + 200 * MIN});
|
|
151
|
+
assert.equal(recent.size, 0);
|
|
152
|
+
assert.equal(recordReset(recent, '', 'a@x.test').tripped, false);
|
|
153
|
+
assert.equal(resetRowText({reason: 'no-smtp'}).note, 'Not sent - no mail server');
|
|
154
|
+
assert.equal(resetRowText({reason: 'odd'}).note, 'odd');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('resets: 10 requests for unknown accounts in an hour, from anywhere, trip once', async () => {
|
|
158
|
+
const {recordUnknown, pruneUnknown} = await import('../admin/lib/resets.js');
|
|
159
|
+
const stamps = [];
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
for (let i = 0; i < 9; i++) assert.equal(recordUnknown(stamps, {now: now + i * MIN}).tripped, false);
|
|
162
|
+
assert.deepEqual(recordUnknown(stamps, {now: now + 9 * MIN}), {count: 10, tripped: true});
|
|
163
|
+
assert.equal(recordUnknown(stamps, {now: now + 10 * MIN}).tripped, false, 'only on reaching it');
|
|
164
|
+
// An hour on, the early ones have gone.
|
|
165
|
+
assert.equal(recordUnknown(stamps, {now: now + 65 * MIN}).count, 7, 'minutes 5-10 still inside the hour, and this one');
|
|
166
|
+
pruneUnknown(stamps, {now: now + 200 * MIN});
|
|
167
|
+
assert.equal(stamps.length, 0);
|
|
168
|
+
});
|
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
* POST /api/auth/me/avatar - upload your picture (multipart `file`); DELETE removes it
|
|
8
8
|
* GET /api/auth/avatar/:name - an avatar image (public: an <img> cannot send the token)
|
|
9
9
|
* POST /api/auth/login/verify - { ticket, response } → tokens, when a sign-in check asked a question (2FA)
|
|
10
|
+
* POST /api/auth/forgot-password - { email } → { ok, expiresIn } - emails a reset link (services/passwordReset.js)
|
|
11
|
+
* POST /api/auth/reset-password/check - { token, password? } → { email, expiresAt, minutesLeft, problems }
|
|
12
|
+
* POST /api/auth/reset-password - { token, password } → { ok } - signs the account out everywhere
|
|
10
13
|
* POST /api/auth/refresh - { refreshToken } → { token } - only while its session is live
|
|
11
14
|
* POST /api/auth/logout - { refreshToken } → { ok: true } - ends the session
|
|
12
15
|
* GET /api/auth/sessions - your sessions; DELETE /api/auth/sessions/:sid; POST /api/auth/sessions/revoke-others
|
|
13
16
|
*
|
|
14
17
|
* Sign-in checks and password checks come from plugins (services/authChecks.js);
|
|
15
18
|
* sessions live in services/sessions.js. Events: auth:loginFailed, auth:loginSucceeded,
|
|
16
|
-
* auth:loggedOut, auth:passwordChanged, auth:rateLimited (and user:loggedIn, as before).
|
|
19
|
+
* auth:loggedOut, auth:passwordChanged, auth:rateLimited, auth:resetRequested (and user:loggedIn, as before).
|
|
20
|
+
* Password reset (the link, where it may point, the email) is services/passwordReset.js.
|
|
17
21
|
*/
|
|
18
22
|
import crypto from 'node:crypto';
|
|
19
23
|
import {config, getConfig} from '../../config.js';
|
|
@@ -30,8 +34,6 @@ import {
|
|
|
30
34
|
createUser,
|
|
31
35
|
getUserByEmail,
|
|
32
36
|
getUserById,
|
|
33
|
-
getUserByResetToken,
|
|
34
|
-
setResetToken,
|
|
35
37
|
touchLastLogin,
|
|
36
38
|
updateUser,
|
|
37
39
|
validatePassword
|
|
@@ -41,7 +43,8 @@ import {acceptedType, AVATAR_DIR, AVATAR_FILE, AVATAR_MAX_BYTES, removeAvatar, s
|
|
|
41
43
|
import fsp from 'node:fs/promises';
|
|
42
44
|
import nodePath from 'node:path';
|
|
43
45
|
import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
|
|
44
|
-
import {
|
|
46
|
+
import {checkResetToken, originOf, recordOrigin, requestReset, resetLifetime} from '../../services/passwordReset.js';
|
|
47
|
+
import {isSmtpConfigured} from '../../services/email.js';
|
|
45
48
|
import {loginFailed, loginRateLimited} from '../../services/notification-sources.js';
|
|
46
49
|
import {afterIndexOf, checkPassword, runLoginChecks, verifyChallenge} from '../../services/authChecks.js';
|
|
47
50
|
import {createSession, isLegacyTokenRevoked, listSessions, revokeLegacyToken, revokeSession, revokeUserSessions, useSession} from '../../services/sessions.js';
|
|
@@ -67,9 +70,12 @@ const passwordProblems = (problems) => problems.join(' ');
|
|
|
67
70
|
|
|
68
71
|
export async function authRoutes(fastify) {
|
|
69
72
|
// GET /api/auth/setup-status
|
|
73
|
+
// `resetByEmail`: whether "Forgot your password?" can work here at all (a mail server is set up).
|
|
70
74
|
fastify.get('/auth/setup-status', async () => {
|
|
71
75
|
const count = await countUsers();
|
|
72
|
-
|
|
76
|
+
const site = getConfig('site') || {};
|
|
77
|
+
return {needsSetup: count === 0, siteTitle: String(site.title || ''), resetByEmail: isSmtpConfigured(site.smtp),
|
|
78
|
+
resetExpiresIn: resetLifetime().text};
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
// POST /api/auth/setup - create the very first admin (only allowed when no users exist)
|
|
@@ -100,6 +106,7 @@ export async function authRoutes(fastify) {
|
|
|
100
106
|
try {
|
|
101
107
|
const user = await createUser({name, email, password, role: 'super-admin'});
|
|
102
108
|
const sid = await createSession({userId: user.id, ip: request.ip, ua: uaOf(request), ttlMs: parseDuration(refreshTokenExpiry)});
|
|
109
|
+
await recordOrigin(request);
|
|
103
110
|
const {token, refreshToken} = signTokens(fastify, user, sid);
|
|
104
111
|
return reply.status(201).send({token, refreshToken, user});
|
|
105
112
|
} finally {
|
|
@@ -179,6 +186,7 @@ export async function authRoutes(fastify) {
|
|
|
179
186
|
const ua = uaOf(request);
|
|
180
187
|
await touchLastLogin(user.id);
|
|
181
188
|
const sid = await createSession({userId: user.id, ip, ua, ttlMs: parseDuration(refreshTokenExpiry)});
|
|
189
|
+
await recordOrigin(request);
|
|
182
190
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
|
|
183
191
|
hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
|
|
184
192
|
hooks.emit('auth:loginSucceeded', {userId: user.id, email: user.email, role: user.role, ip, ua, sid});
|
|
@@ -386,86 +394,55 @@ export async function authRoutes(fastify) {
|
|
|
386
394
|
.send(data);
|
|
387
395
|
});
|
|
388
396
|
|
|
389
|
-
// POST /api/auth/forgot-password -
|
|
397
|
+
// POST /api/auth/forgot-password - email a reset link (no auth)
|
|
398
|
+
// Answers at once and the same way for every address: the lookup and the send happen
|
|
399
|
+
// after the reply, so neither the answer nor how long it took says whether an account exists.
|
|
390
400
|
fastify.post('/auth/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (request) => {
|
|
391
401
|
const {email} = request.body || {};
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const user = await getUserByEmail(email);
|
|
396
|
-
if (!user || !user.isActive) return {ok: true};
|
|
397
|
-
|
|
398
|
-
const token = crypto.randomBytes(32).toString('hex');
|
|
399
|
-
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
400
|
-
|
|
401
|
-
const authCfg = getConfig('auth');
|
|
402
|
-
const expiryMs = parseDuration(authCfg.resetTokenExpiry || '1h');
|
|
403
|
-
const expiry = new Date(Date.now() + expiryMs).toISOString();
|
|
404
|
-
|
|
405
|
-
await setResetToken(user.id, hash, expiry);
|
|
406
|
-
|
|
407
|
-
const siteConfig = getConfig('site');
|
|
408
|
-
const smtp = siteConfig.smtp || {};
|
|
409
|
-
const sitePort = config.server.port || 4096;
|
|
410
|
-
const resetUrl = `${request.protocol}://${request.hostname}:${sitePort}/admin/#/reset-password?token=${token}`;
|
|
411
|
-
|
|
412
|
-
const transport = await createTransport(smtp);
|
|
413
|
-
const from = smtp.fromAddress || 'noreply@example.com';
|
|
414
|
-
const fromName = smtp.fromName || siteConfig.title || 'Domma CMS';
|
|
415
|
-
|
|
416
|
-
await sendEmail(transport, {
|
|
417
|
-
from,
|
|
418
|
-
fromName,
|
|
419
|
-
to: user.email,
|
|
420
|
-
subject: 'Reset your password',
|
|
421
|
-
html: `
|
|
422
|
-
<!DOCTYPE html>
|
|
423
|
-
<html>
|
|
424
|
-
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
425
|
-
<h2 style="color:#333;">Password Reset</h2>
|
|
426
|
-
<p>Hi ${user.name},</p>
|
|
427
|
-
<p>We received a request to reset your password. Click the link below - it expires in 1 hour.</p>
|
|
428
|
-
<p style="margin:24px 0;">
|
|
429
|
-
<a href="${resetUrl}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Reset Password</a>
|
|
430
|
-
</p>
|
|
431
|
-
<p style="color:#888;font-size:.85rem;">If you didn't request this, you can safely ignore this email.</p>
|
|
432
|
-
<p style="color:#bbb;font-size:.8rem;">${resetUrl}</p>
|
|
433
|
-
</body>
|
|
434
|
-
</html>`.trim(),
|
|
435
|
-
text: `Hi ${user.name},\n\nReset your password here:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`
|
|
436
|
-
});
|
|
437
|
-
} catch (err) {
|
|
438
|
-
console.error('[auth] forgot-password error:', err.message);
|
|
402
|
+
if (typeof email === 'string' && email.trim() && email.length <= 320) {
|
|
403
|
+
const job = {email, origin: originOf(request), ip: request.ip, ua: uaOf(request), log: fastify.log};
|
|
404
|
+
setImmediate(() => { requestReset(job).catch(() => {}); });
|
|
439
405
|
}
|
|
406
|
+
return {ok: true, expiresIn: resetLifetime().text};
|
|
407
|
+
});
|
|
440
408
|
|
|
441
|
-
|
|
409
|
+
// POST /api/auth/reset-password/check - {token, password?}: is the link still good (the screen asks on
|
|
410
|
+
// opening, so an expired link says so before anything is typed), and - with a password - what the
|
|
411
|
+
// site's password rules make of it, as it is typed. Only ever for the holder of a live link.
|
|
412
|
+
fastify.post('/auth/reset-password/check', {config: {rateLimit: {max: 60, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
413
|
+
const {token, password} = request.body || {};
|
|
414
|
+
const user = await checkResetToken(token);
|
|
415
|
+
if (!user) return reply.status(400).send({error: 'Invalid or expired reset link', expired: true});
|
|
416
|
+
let problems = [];
|
|
417
|
+
if (typeof password === 'string' && password) {
|
|
418
|
+
problems = password.length < 8 ? ['Password must be at least 8 characters.']
|
|
419
|
+
: await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
|
|
420
|
+
}
|
|
421
|
+
const ms = Date.parse(user.resetTokenExpiry) - Date.now();
|
|
422
|
+
return {email: user.email, expiresAt: user.resetTokenExpiry, minutesLeft: Math.max(1, Math.ceil(ms / 60_000)), problems};
|
|
442
423
|
});
|
|
443
424
|
|
|
444
425
|
// POST /api/auth/reset-password - consume token and set new password (no auth)
|
|
445
426
|
fastify.post('/auth/reset-password', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
|
|
446
427
|
const {token, password} = request.body || {};
|
|
447
|
-
if (!token || !password) {
|
|
428
|
+
if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
|
|
448
429
|
return reply.status(400).send({error: 'token and password are required'});
|
|
449
430
|
}
|
|
450
431
|
if (password.length < 8) {
|
|
451
432
|
return reply.status(400).send({error: 'Password must be at least 8 characters'});
|
|
452
433
|
}
|
|
453
434
|
|
|
454
|
-
const
|
|
455
|
-
const user = await getUserByResetToken(hash);
|
|
435
|
+
const user = await checkResetToken(token);
|
|
456
436
|
if (!user) {
|
|
457
|
-
return reply.status(400).send({error: 'Invalid or expired reset link'});
|
|
458
|
-
}
|
|
459
|
-
if (new Date(user.resetTokenExpiry) < new Date()) {
|
|
460
|
-
await clearResetToken(user.id);
|
|
461
|
-
return reply.status(400).send({error: 'Invalid or expired reset link'});
|
|
437
|
+
return reply.status(400).send({error: 'Invalid or expired reset link', expired: true});
|
|
462
438
|
}
|
|
463
439
|
|
|
464
440
|
const problems = await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
|
|
465
441
|
if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
|
|
466
442
|
|
|
467
|
-
|
|
443
|
+
// Used up BEFORE the password is set: two submissions at once must not both get through.
|
|
468
444
|
await clearResetToken(user.id);
|
|
445
|
+
await updateUser(user.id, {password});
|
|
469
446
|
await revokeUserSessions(user.id);
|
|
470
447
|
hooks.emit('auth:passwordChanged', {userId: user.id, by: 'reset', ip: request.ip, ua: uaOf(request)});
|
|
471
448
|
return {ok: true};
|
|
@@ -520,6 +497,7 @@ export async function authRoutes(fastify) {
|
|
|
520
497
|
return reply.status(401).send({ error: 'User not found or inactive' });
|
|
521
498
|
}
|
|
522
499
|
|
|
500
|
+
await recordOrigin(request);
|
|
523
501
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
|
|
524
502
|
const token = fastify.jwt.sign({ ...safeUser, type: 'access', ...(payload.sid && {sid: payload.sid}) }, { expiresIn: accessTokenExpiry });
|
|
525
503
|
setSessionCookie(request, reply, token);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* POST /forms/submit/:slug - public: accept submission
|
|
20
20
|
* POST /forms/test-email - admin: send test email
|
|
21
21
|
*/
|
|
22
|
+
import {keepSubmissionsOut} from '../../services/siteGitignore.js';
|
|
22
23
|
import {createRequire} from 'module';
|
|
23
24
|
import {fileURLToPath} from 'url';
|
|
24
25
|
import path from 'path';
|
|
@@ -893,6 +894,8 @@ export async function formsRoutes(fastify) {
|
|
|
893
894
|
excused,
|
|
894
895
|
outcome: ended ? {type: 'ended', trigger: ended.key, field: ended.field} : null
|
|
895
896
|
});
|
|
897
|
+
// What a visitor sent is theirs, not the site's content: keep it out of the site repo.
|
|
898
|
+
try { keepSubmissionsOut(ROOT, targetSlug); } catch { /* never fail a submission over this */ }
|
|
896
899
|
} else {
|
|
897
900
|
// Could not resolve or provision a target - e.g. collection
|
|
898
901
|
// storage is explicitly disabled with no slug. Fail loudly
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
savePluginState
|
|
24
24
|
} from '../../services/plugins.js';
|
|
25
25
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
26
|
+
import {getToolStates, planSwitch, requiresOf, switchableUnits, usesOf} from '../../services/tools.js';
|
|
27
|
+
import {applyCascade} from './tools.js';
|
|
26
28
|
import {scaffoldPlugin} from '../../services/pluginScaffold.js';
|
|
27
29
|
import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
|
|
28
30
|
import {getConfig} from '../../config.js';
|
|
@@ -83,14 +85,14 @@ let _restartTimer = null;
|
|
|
83
85
|
* @param {number} [delayMs]
|
|
84
86
|
* @returns {void}
|
|
85
87
|
*/
|
|
86
|
-
function scheduleRestart(exit, delayMs = 1500) {
|
|
88
|
+
export function scheduleRestart(exit, delayMs = 1500) {
|
|
87
89
|
if (_restartTimer) clearTimeout(_restartTimer);
|
|
88
90
|
_restartTimer = setTimeout(exit, delayMs);
|
|
89
91
|
_restartTimer.unref?.();
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/** Admins can opt out of automatic restarts in config/server.json. */
|
|
93
|
-
function autoRestartEnabled() {
|
|
95
|
+
export function autoRestartEnabled() {
|
|
94
96
|
try {
|
|
95
97
|
return getConfig('server')?.restartOnPluginToggle !== false;
|
|
96
98
|
} catch {
|
|
@@ -251,6 +253,13 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
251
253
|
// Enabled but displaced by another plugin that replaces it. Shown
|
|
252
254
|
// so the screen can explain the inactivity instead of looking broken.
|
|
253
255
|
supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
|
|
256
|
+
// Enabled but not running because something it requires is not
|
|
257
|
+
// (a built-in Tool switched off, a required plugin that is off).
|
|
258
|
+
blockedBy: loadedPlugins[manifest.name]?.blockedBy ?? null,
|
|
259
|
+
// The site's manager holds this switch: the admin here cannot move it.
|
|
260
|
+
locked: states[manifest.name]?.locked === true,
|
|
261
|
+
requires: requiresOf(manifest),
|
|
262
|
+
uses: usesOf(manifest),
|
|
254
263
|
settings: maskSettings(states[manifest.name]?.settings || {}, '', schemaSecrets(manifest)),
|
|
255
264
|
// What Configure edits: an optional plugin.json `settingsSchema`
|
|
256
265
|
// ([{key, label, type: text|number|select|boolean, options, min,
|
|
@@ -311,6 +320,27 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
311
320
|
}
|
|
312
321
|
|
|
313
322
|
const prevState = getPluginStates()[name] || {};
|
|
323
|
+
|
|
324
|
+
// The switch goes through the same planner the Manager uses: a lock
|
|
325
|
+
// refuses it, and anything it drags along (a plugin that requires this
|
|
326
|
+
// one going off, a requirement coming on) is answered as a plan to
|
|
327
|
+
// confirm - `cascade: true` on the repeat applies it.
|
|
328
|
+
let cascaded = [];
|
|
329
|
+
if (enabled !== undefined && !!enabled !== !!prevState.enabled) {
|
|
330
|
+
const plan = planSwitch(switchableUnits({manifests, pluginStates: getPluginStates(), toolStates: getToolStates()}),
|
|
331
|
+
{[name]: !!enabled});
|
|
332
|
+
if (!plan.ok) {
|
|
333
|
+
const r = plan.refused[0];
|
|
334
|
+
const who = r.name === name ? (manifest.displayName || name) : r.name;
|
|
335
|
+
return reply.status(r.reason.includes('locked') ? 423 : 409).send({error: `${who} ${r.reason}.`, plan});
|
|
336
|
+
}
|
|
337
|
+
cascaded = plan.cascaded;
|
|
338
|
+
if ((cascaded.length || plan.losses.length) && request.body?.cascade !== true) {
|
|
339
|
+
return reply.status(409).send({error: 'This switch changes other Tools too.', needsConfirm: true, plan});
|
|
340
|
+
}
|
|
341
|
+
await applyCascade(cascaded, fastify);
|
|
342
|
+
}
|
|
343
|
+
|
|
314
344
|
const update = {};
|
|
315
345
|
if (enabled !== undefined) update.enabled = !!enabled;
|
|
316
346
|
// A secret sent back as the mask keeps its stored value (see maskSettings).
|
|
@@ -329,7 +359,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
329
359
|
// but only where a supervisor will bring the process back, and only
|
|
330
360
|
// when the enabled flag actually changed (a settings-only save must not
|
|
331
361
|
// bounce the site).
|
|
332
|
-
const toggled = enabled !== undefined && !!enabled !== !!prevState.enabled;
|
|
362
|
+
const toggled = (enabled !== undefined && !!enabled !== !!prevState.enabled) || cascaded.length > 0;
|
|
333
363
|
const supervised = hasSupervisor();
|
|
334
364
|
const restarting = toggled && supervised && autoRestartEnabled();
|
|
335
365
|
if (restarting) scheduleRestart(exit);
|