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,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password reset - "Forgot your password?" and the link it emails.
|
|
3
|
+
*
|
|
4
|
+
* Where the link points is the part that matters. It used to be built from
|
|
5
|
+
* the request - `protocol://hostname:<server port>` - which failed two ways:
|
|
6
|
+
* - behind a proxy (every fleet site) the link carried the INTERNAL port,
|
|
7
|
+
* `https://example.com:4096/...`, and did not open;
|
|
8
|
+
* - the host is whatever the caller claims. Anyone could ask for a reset of
|
|
9
|
+
* your account "from" evil.com, and the site mailed YOU a genuine link, with
|
|
10
|
+
* a live token, to evil.com.
|
|
11
|
+
*
|
|
12
|
+
* So the link goes only to an origin the site trusts:
|
|
13
|
+
* 1. `site.baseUrl`, when it is set - always, whatever the request says;
|
|
14
|
+
* 2. otherwise the origin of this request IF someone has signed in there
|
|
15
|
+
* (recordOrigin() notes it at setup, sign-in and token refresh - an
|
|
16
|
+
* attacker cannot sign in, so cannot add one);
|
|
17
|
+
* 3. otherwise the origin signed in at most recently;
|
|
18
|
+
* 4. otherwise none: nothing is sent, and the admins are told why.
|
|
19
|
+
* Known origins live in content/sessions/origins.json - runtime state, never
|
|
20
|
+
* committed (siteGitignore.js ignores content/sessions/).
|
|
21
|
+
*
|
|
22
|
+
* The route answers before any of this runs (requestReset is not awaited), so
|
|
23
|
+
* how long it takes cannot tell a caller whether an account exists.
|
|
24
|
+
*
|
|
25
|
+
* Also here:
|
|
26
|
+
* - a per-account limit on self-service emails (SELF_SERVICE), so nobody
|
|
27
|
+
* can fill an inbox from many addresses - the route's limit is per IP;
|
|
28
|
+
* - an administrator's reset (Users › ⋮ › Send password reset / Copy a
|
|
29
|
+
* reset link) - adminReset(); the admin's own request is signed in, so its
|
|
30
|
+
* origin is trusted as it stands;
|
|
31
|
+
* - checkResetToken() for the reset screen, which checks the link on
|
|
32
|
+
* opening rather than after a new password has been typed twice;
|
|
33
|
+
* - the "your password was changed" email on every auth:passwordChanged
|
|
34
|
+
* (registerPasswordChangeNotice(), called once at boot by server.js).
|
|
35
|
+
*/
|
|
36
|
+
import crypto from 'node:crypto';
|
|
37
|
+
import fs from 'node:fs/promises';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import {config, getConfig} from '../config.js';
|
|
40
|
+
import {hooks} from './hooks.js';
|
|
41
|
+
import {getUserByEmail, getUserById, getUserByResetToken, setResetToken} from './users.js';
|
|
42
|
+
import * as email from './email.js';
|
|
43
|
+
|
|
44
|
+
export const ORIGINS_FILE = path.resolve(path.dirname(config.content.usersDir), 'sessions', 'origins.json');
|
|
45
|
+
const MAX_ORIGINS = 20;
|
|
46
|
+
const REFRESH_MS = 86_400_000;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Durations
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* '30m' / '1h' / '2d' in milliseconds (anything else is an hour).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} str
|
|
56
|
+
* @returns {number}
|
|
57
|
+
*/
|
|
58
|
+
export function durationMs(str) {
|
|
59
|
+
const m = String(str ?? '').trim().match(/^(\d+)([mhd])$/);
|
|
60
|
+
if (!m || Number(m[1]) <= 0) return 3_600_000;
|
|
61
|
+
return Number(m[1]) * {m: 60_000, h: 3_600_000, d: 86_400_000}[m[2]];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A duration in words: '1 hour', '30 minutes', '2 days'.
|
|
66
|
+
*
|
|
67
|
+
* @param {number} ms
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function durationText(ms) {
|
|
71
|
+
const units = [['day', 86_400_000], ['hour', 3_600_000], ['minute', 60_000]];
|
|
72
|
+
for (const [name, size] of units) {
|
|
73
|
+
if (ms >= size && ms % size === 0) {
|
|
74
|
+
const n = ms / size;
|
|
75
|
+
return `${n} ${name}${n === 1 ? '' : 's'}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const n = Math.max(1, Math.round(ms / 60_000));
|
|
79
|
+
return `${n} minute${n === 1 ? '' : 's'}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How long a reset link lives, from config/auth.json `resetTokenExpiry`. */
|
|
83
|
+
export function resetLifetime() {
|
|
84
|
+
const ms = durationMs(getConfig('auth')?.resetTokenExpiry || '1h');
|
|
85
|
+
return {ms, text: durationText(ms)};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Trusted origins
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const ORIGIN_RE = /^https?:\/\/[a-z0-9.\-]+(:\d{1,5})?$|^https?:\/\/\[[0-9a-f:.]+\](:\d{1,5})?$/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 'https://example.com' - lower case, no path, no trailing slash; '' when it
|
|
96
|
+
* does not look like an origin.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} value
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function normaliseOrigin(value) {
|
|
102
|
+
const s = String(value ?? '').trim().toLowerCase().replace(/\/+$/, '');
|
|
103
|
+
return ORIGIN_RE.test(s) ? s : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* This request's origin as Fastify resolves it (X-Forwarded-* when trustProxy
|
|
108
|
+
* is on). `request.host` keeps a non-default port and drops 80/443.
|
|
109
|
+
*
|
|
110
|
+
* @param {import('fastify').FastifyRequest} request
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function originOf(request) {
|
|
114
|
+
const host = request?.host || request?.headers?.host || request?.hostname || '';
|
|
115
|
+
return normaliseOrigin(`${request?.protocol || 'http'}://${host}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let origins = null;
|
|
119
|
+
let queue = Promise.resolve();
|
|
120
|
+
|
|
121
|
+
async function loadOrigins() {
|
|
122
|
+
if (origins) return origins;
|
|
123
|
+
try {
|
|
124
|
+
const raw = JSON.parse(await fs.readFile(ORIGINS_FILE, 'utf8'));
|
|
125
|
+
origins = raw && typeof raw.origins === 'object' && raw.origins ? raw.origins : {};
|
|
126
|
+
} catch {
|
|
127
|
+
origins = {};
|
|
128
|
+
}
|
|
129
|
+
return origins;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Note that someone signed in at this request's origin. Written at most once a
|
|
134
|
+
* day per origin; the oldest go past MAX_ORIGINS. Never throws - a sign-in must
|
|
135
|
+
* not fail over this.
|
|
136
|
+
*
|
|
137
|
+
* @param {import('fastify').FastifyRequest} request
|
|
138
|
+
* @returns {Promise<void>}
|
|
139
|
+
*/
|
|
140
|
+
export function recordOrigin(request) {
|
|
141
|
+
const origin = originOf(request);
|
|
142
|
+
if (!origin) return Promise.resolve();
|
|
143
|
+
const run = queue.then(async () => {
|
|
144
|
+
const known = await loadOrigins();
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (known[origin] && now - Date.parse(known[origin]) < REFRESH_MS) return;
|
|
147
|
+
known[origin] = new Date(now).toISOString();
|
|
148
|
+
const sorted = Object.entries(known).sort((a, b) => Date.parse(b[1]) - Date.parse(a[1]));
|
|
149
|
+
origins = Object.fromEntries(sorted.slice(0, MAX_ORIGINS));
|
|
150
|
+
await fs.mkdir(path.dirname(ORIGINS_FILE), {recursive: true});
|
|
151
|
+
const tmp = `${ORIGINS_FILE}.${process.pid}.tmp`;
|
|
152
|
+
await fs.writeFile(tmp, JSON.stringify({origins}, null, 2), {mode: 0o600});
|
|
153
|
+
await fs.rename(tmp, ORIGINS_FILE);
|
|
154
|
+
});
|
|
155
|
+
queue = run.catch(() => {});
|
|
156
|
+
return queue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The origin a reset link may point at, or '' when the site knows none.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} requestOrigin - originOf(request)
|
|
163
|
+
* @returns {Promise<string>}
|
|
164
|
+
*/
|
|
165
|
+
export async function trustedOrigin(requestOrigin) {
|
|
166
|
+
const base = normaliseOrigin(getConfig('site')?.baseUrl);
|
|
167
|
+
if (base) return base;
|
|
168
|
+
await queue;
|
|
169
|
+
const known = await loadOrigins();
|
|
170
|
+
const origin = normaliseOrigin(requestOrigin);
|
|
171
|
+
if (origin && known[origin]) return origin;
|
|
172
|
+
const latest = Object.entries(known).sort((a, b) => Date.parse(b[1]) - Date.parse(a[1]))[0];
|
|
173
|
+
return latest ? latest[0] : '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Tests: forget the cached origins (the file is re-read). */
|
|
177
|
+
export function _resetOrigins() {
|
|
178
|
+
origins = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// The email
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
186
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} origin
|
|
190
|
+
* @param {string} token
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function resetLink(origin, token) {
|
|
194
|
+
return `${origin}/admin/#/reset-password?token=${token}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The reset email. Everything a user or an admin typed is escaped.
|
|
199
|
+
*
|
|
200
|
+
* @param {{name: string, link: string, lifetime: string, siteTitle: string, requestedBy?: string|true}} o
|
|
201
|
+
* `requestedBy`: an administrator's name (their reset from Users), or true when it is not known
|
|
202
|
+
* @returns {{subject: string, html: string, text: string}}
|
|
203
|
+
*/
|
|
204
|
+
export function buildResetEmail({name, link, lifetime, siteTitle, requestedBy = ''}) {
|
|
205
|
+
const site = siteTitle || 'the site';
|
|
206
|
+
const hi = name ? `Hi ${name},` : 'Hello,';
|
|
207
|
+
if (requestedBy) return buildAdminResetEmail({hi, site, link, lifetime, requestedBy});
|
|
208
|
+
return {
|
|
209
|
+
subject: `Reset your password for ${site}`,
|
|
210
|
+
html: `
|
|
211
|
+
<!DOCTYPE html>
|
|
212
|
+
<html>
|
|
213
|
+
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
214
|
+
<h2 style="color:#333;">Reset your password</h2>
|
|
215
|
+
<p>${esc(hi)}</p>
|
|
216
|
+
<p>Someone asked to reset the password for your account on ${esc(site)}. Use the button below - it works once and expires in ${esc(lifetime)}.</p>
|
|
217
|
+
<p style="margin:24px 0;">
|
|
218
|
+
<a href="${esc(link)}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Choose a new password</a>
|
|
219
|
+
</p>
|
|
220
|
+
<p style="color:#888;font-size:.85rem;">If it was not you, ignore this email - your password stays as it is.</p>
|
|
221
|
+
<p style="color:#bbb;font-size:.8rem;">${esc(link)}</p>
|
|
222
|
+
</body>
|
|
223
|
+
</html>`.trim(),
|
|
224
|
+
text: `${hi}\n\nSomeone asked to reset the password for your account on ${site}.\n\n`
|
|
225
|
+
+ `Choose a new password here (it works once and expires in ${lifetime}):\n${link}\n\n`
|
|
226
|
+
+ 'If it was not you, ignore this email - your password stays as it is.'
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The same link, sent by an administrator from Users - it says who asked. */
|
|
231
|
+
function buildAdminResetEmail({hi, site, link, lifetime, requestedBy}) {
|
|
232
|
+
requestedBy = requestedBy === true ? 'An administrator of' : `${requestedBy}, an administrator of`;
|
|
233
|
+
return {
|
|
234
|
+
subject: `Set a new password for ${site}`,
|
|
235
|
+
html: `
|
|
236
|
+
<!DOCTYPE html>
|
|
237
|
+
<html>
|
|
238
|
+
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
239
|
+
<h2 style="color:#333;">Set a new password</h2>
|
|
240
|
+
<p>${esc(hi)}</p>
|
|
241
|
+
<p>${esc(requestedBy)} ${esc(site)} sent you this link to set a new password for your account. It works once and expires in ${esc(lifetime)}.</p>
|
|
242
|
+
<p style="margin:24px 0;">
|
|
243
|
+
<a href="${esc(link)}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Choose a new password</a>
|
|
244
|
+
</p>
|
|
245
|
+
<p style="color:#888;font-size:.85rem;">Not expecting this? Ignore it - your password stays as it is until the link is used.</p>
|
|
246
|
+
<p style="color:#bbb;font-size:.8rem;">${esc(link)}</p>
|
|
247
|
+
</body>
|
|
248
|
+
</html>`.trim(),
|
|
249
|
+
text: `${hi}\n\n${requestedBy} ${site} sent you this link to set a new password for your account `
|
|
250
|
+
+ `(it works once and expires in ${lifetime}):\n${link}\n\n`
|
|
251
|
+
+ 'Not expecting this? Ignore it - your password stays as it is until the link is used.'
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const HOW = {
|
|
256
|
+
reset: 'using a password reset link',
|
|
257
|
+
self: 'from your own account (My Profile)',
|
|
258
|
+
admin: 'by an administrator'
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* "Your password was changed" - to the account holder, after any change.
|
|
263
|
+
*
|
|
264
|
+
* @param {{name: string, how: 'reset'|'self'|'admin', when: string, siteTitle: string, signInUrl?: string}} o
|
|
265
|
+
* @returns {{subject: string, html: string, text: string}}
|
|
266
|
+
*/
|
|
267
|
+
export function buildChangedEmail({name, how, when, siteTitle, signInUrl = ''}) {
|
|
268
|
+
const site = siteTitle || 'the site';
|
|
269
|
+
const hi = name ? `Hi ${name},` : 'Hello,';
|
|
270
|
+
const line = `The password for your account on ${site} was changed ${HOW[how] || HOW.self} at ${when}.`;
|
|
271
|
+
const notYou = signInUrl
|
|
272
|
+
? `If that was not you, choose a new password now with "Forgot your password?" at ${signInUrl}, and tell an administrator.`
|
|
273
|
+
: 'If that was not you, tell an administrator of the site at once.';
|
|
274
|
+
return {
|
|
275
|
+
subject: `Your password for ${site} was changed`,
|
|
276
|
+
html: `
|
|
277
|
+
<!DOCTYPE html>
|
|
278
|
+
<html>
|
|
279
|
+
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
280
|
+
<h2 style="color:#333;">Your password was changed</h2>
|
|
281
|
+
<p>${esc(hi)}</p>
|
|
282
|
+
<p>${esc(line)} Anywhere else you were signed in has been signed out.</p>
|
|
283
|
+
<p>If it was you, there is nothing more to do.</p>
|
|
284
|
+
<p style="color:#888;font-size:.85rem;">${esc(notYou)}</p>
|
|
285
|
+
</body>
|
|
286
|
+
</html>`.trim(),
|
|
287
|
+
text: `${hi}\n\n${line} Anywhere else you were signed in has been signed out.\n\n`
|
|
288
|
+
+ `If it was you, there is nothing more to do.\n\n${notYou}`
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Issuing
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/** Tests swap the mailer so nothing touches the network (email.js always does). */
|
|
297
|
+
let mailer = email;
|
|
298
|
+
export function _setMailer(m) {
|
|
299
|
+
mailer = m ? {...email, ...m} : email;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Self-service emails per account: the route's own limit is per address, so
|
|
304
|
+
* without this anyone with a few addresses could fill someone's inbox (and
|
|
305
|
+
* keep replacing the link they are about to use). An administrator's reset is
|
|
306
|
+
* not counted.
|
|
307
|
+
*/
|
|
308
|
+
export const SELF_SERVICE = {max: 3, windowMs: 3_600_000};
|
|
309
|
+
const recent = new Map();
|
|
310
|
+
|
|
311
|
+
function throttled(userId, now = Date.now()) {
|
|
312
|
+
const list = (recent.get(userId) || []).filter(t => now - t < SELF_SERVICE.windowMs);
|
|
313
|
+
const over = list.length >= SELF_SERVICE.max;
|
|
314
|
+
if (!over) list.push(now);
|
|
315
|
+
recent.set(userId, list);
|
|
316
|
+
return over;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Tests: forget the per-account counts. */
|
|
320
|
+
export function _resetThrottle() {
|
|
321
|
+
recent.clear();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function fromOf(site) {
|
|
325
|
+
const smtp = site.smtp || {};
|
|
326
|
+
return {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || site.title || 'Domma CMS'};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A new reset token for a user; replaces any earlier one.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} userId
|
|
333
|
+
* @returns {Promise<{token: string, expiresAt: string}>}
|
|
334
|
+
*/
|
|
335
|
+
export async function issueResetToken(userId) {
|
|
336
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
337
|
+
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
338
|
+
const expiresAt = new Date(Date.now() + resetLifetime().ms).toISOString();
|
|
339
|
+
await setResetToken(userId, hash, expiresAt);
|
|
340
|
+
return {token, expiresAt};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Mail a reset link to the account behind `email`, if there is an active one.
|
|
345
|
+
* Emits `auth:resetRequested` {email, userId, ip, ua, sent, reason} every time,
|
|
346
|
+
* whatever the outcome.
|
|
347
|
+
*
|
|
348
|
+
* @param {{email: string, origin: string, ip?: string, ua?: string, log?: object}} o
|
|
349
|
+
* `origin` is originOf(request), taken before the route answered.
|
|
350
|
+
* @returns {Promise<{sent: boolean, reason: string}>}
|
|
351
|
+
* reason: 'sent' | 'unknown' | 'inactive' | 'throttled' | 'no-smtp' | 'no-origin' | 'failed'
|
|
352
|
+
*/
|
|
353
|
+
export async function requestReset({email: address, origin, ip = '', ua = '', log = console}) {
|
|
354
|
+
const addr = String(address ?? '').trim().toLowerCase();
|
|
355
|
+
let user = null;
|
|
356
|
+
let outcome;
|
|
357
|
+
try {
|
|
358
|
+
user = addr ? await getUserByEmail(addr) : null;
|
|
359
|
+
outcome = await send(user, origin, log);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
log.error?.(`[auth] password reset failed: ${err.message}`);
|
|
362
|
+
outcome = {sent: false, reason: 'failed'};
|
|
363
|
+
}
|
|
364
|
+
hooks.emit('auth:resetRequested', {email: addr, userId: user?.id ?? null, ip, ua, ...outcome});
|
|
365
|
+
return outcome;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function send(user, origin, log) {
|
|
369
|
+
if (!user) return {sent: false, reason: 'unknown'};
|
|
370
|
+
if (!user.isActive) return {sent: false, reason: 'inactive'};
|
|
371
|
+
|
|
372
|
+
const site = getConfig('site') || {};
|
|
373
|
+
const smtp = site.smtp || {};
|
|
374
|
+
const {resetNotSent} = await import('./notification-sources.js');
|
|
375
|
+
// Without a mail server the message would go to a throwaway test inbox:
|
|
376
|
+
// the user would wait for an email that never comes. Say so instead.
|
|
377
|
+
if (!mailer.isSmtpConfigured(smtp)) {
|
|
378
|
+
resetNotSent('no-smtp', user.email);
|
|
379
|
+
return {sent: false, reason: 'no-smtp'};
|
|
380
|
+
}
|
|
381
|
+
const trusted = await trustedOrigin(origin);
|
|
382
|
+
if (!trusted) {
|
|
383
|
+
resetNotSent('no-origin', user.email);
|
|
384
|
+
log.warn?.('[auth] password reset not sent: no trusted site address (set site.baseUrl, or sign in once)');
|
|
385
|
+
return {sent: false, reason: 'no-origin'};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (throttled(user.id)) {
|
|
389
|
+
log.warn?.(`[auth] password reset for ${user.email} not sent: over ${SELF_SERVICE.max} in an hour`);
|
|
390
|
+
return {sent: false, reason: 'throttled'};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const {token} = await issueResetToken(user.id);
|
|
394
|
+
const message = buildResetEmail({name: user.name, link: resetLink(trusted, token), lifetime: resetLifetime().text,
|
|
395
|
+
siteTitle: site.title});
|
|
396
|
+
const transport = await mailer.createTransport(smtp);
|
|
397
|
+
await mailer.sendEmail(transport, {...fromOf(site), to: user.email, ...message});
|
|
398
|
+
return {sent: true, reason: 'sent'};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// An administrator's reset
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The origin for a link an administrator makes: site.baseUrl, else the
|
|
407
|
+
* address the admin is signed in at (their request is authenticated - it is
|
|
408
|
+
* recorded as known, too).
|
|
409
|
+
*
|
|
410
|
+
* @param {import('fastify').FastifyRequest} request
|
|
411
|
+
* @returns {Promise<string>}
|
|
412
|
+
*/
|
|
413
|
+
export async function adminOrigin(request) {
|
|
414
|
+
await recordOrigin(request);
|
|
415
|
+
return normaliseOrigin(getConfig('site')?.baseUrl) || originOf(request);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A reset from Users: email the link, or hand it back to copy. The caller has
|
|
420
|
+
* already checked the admin may manage this user.
|
|
421
|
+
*
|
|
422
|
+
* @param {{user: object, origin: string, via: 'email'|'link', by: {id: string, name?: string, email?: string}}} o
|
|
423
|
+
* @returns {Promise<{expiresAt: string, expiresIn: string, link?: string, sent?: boolean}>}
|
|
424
|
+
* @throws {Error} code 'NO_SMTP' (email asked, no mail server) | 'INACTIVE' | 'NO_ORIGIN'
|
|
425
|
+
*/
|
|
426
|
+
export async function adminReset({user, origin, via, by}) {
|
|
427
|
+
const fail = (code, message) => Object.assign(new Error(message), {code});
|
|
428
|
+
if (!user.isActive) throw fail('INACTIVE', 'This account is inactive - make it active first, or it cannot sign in with the new password.');
|
|
429
|
+
const site = getConfig('site') || {};
|
|
430
|
+
if (via === 'email' && !mailer.isSmtpConfigured(site.smtp)) {
|
|
431
|
+
throw fail('NO_SMTP', 'No mail server is set up (Settings › Email), so the link cannot be emailed. Copy it instead.');
|
|
432
|
+
}
|
|
433
|
+
if (!origin) throw fail('NO_ORIGIN', 'The site does not know its own address. Set the Site URL in Settings › General.');
|
|
434
|
+
const {token, expiresAt} = await issueResetToken(user.id);
|
|
435
|
+
const link = resetLink(origin, token);
|
|
436
|
+
const expiresIn = resetLifetime().text;
|
|
437
|
+
hooks.emit('auth:resetIssued', {userId: user.id, by: by?.id ?? null, via});
|
|
438
|
+
if (via === 'link') return {link, expiresAt, expiresIn};
|
|
439
|
+
const message = buildResetEmail({name: user.name, link, lifetime: expiresIn, siteTitle: site.title,
|
|
440
|
+
requestedBy: by?.name || by?.email || true});
|
|
441
|
+
const transport = await mailer.createTransport(site.smtp);
|
|
442
|
+
await mailer.sendEmail(transport, {...fromOf(site), to: user.email, ...message});
|
|
443
|
+
return {sent: true, expiresAt, expiresIn};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// The reset screen
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The account a reset token belongs to, while it may still be used: known,
|
|
452
|
+
* unexpired and the account active. null otherwise - the caller says
|
|
453
|
+
* "invalid or expired" either way.
|
|
454
|
+
*
|
|
455
|
+
* @param {string} token - the 64-hex token from the link
|
|
456
|
+
* @returns {Promise<object|null>} the full user record (callers pick fields)
|
|
457
|
+
*/
|
|
458
|
+
export async function checkResetToken(token) {
|
|
459
|
+
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) return null;
|
|
460
|
+
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
461
|
+
const user = await getUserByResetToken(hash);
|
|
462
|
+
if (!user || !user.isActive) return null;
|
|
463
|
+
if (!(Date.parse(user.resetTokenExpiry) > Date.now())) return null;
|
|
464
|
+
return user;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// "Your password was changed"
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Tell the account holder their password changed. Never to a test inbox:
|
|
473
|
+
* without a mail server it is skipped.
|
|
474
|
+
*
|
|
475
|
+
* @param {{userId: string, by: string}} e - auth:passwordChanged; `by` is 'reset' or the actor's id
|
|
476
|
+
* @returns {Promise<{sent: boolean, reason: string}>}
|
|
477
|
+
*/
|
|
478
|
+
export async function passwordChangedNotice({userId, by}) {
|
|
479
|
+
const user = userId ? await getUserById(userId) : null;
|
|
480
|
+
if (!user?.email) return {sent: false, reason: 'unknown'};
|
|
481
|
+
const site = getConfig('site') || {};
|
|
482
|
+
if (!mailer.isSmtpConfigured(site.smtp)) return {sent: false, reason: 'no-smtp'};
|
|
483
|
+
const how = by === 'reset' ? 'reset' : by === userId ? 'self' : 'admin';
|
|
484
|
+
const origin = await trustedOrigin('');
|
|
485
|
+
const when = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
486
|
+
const message = buildChangedEmail({name: user.name, how, when, siteTitle: site.title,
|
|
487
|
+
signInUrl: origin ? `${origin}/admin/#/login` : ''});
|
|
488
|
+
const transport = await mailer.createTransport(site.smtp);
|
|
489
|
+
await mailer.sendEmail(transport, {...fromOf(site), to: user.email, ...message});
|
|
490
|
+
return {sent: true, reason: 'sent'};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let noticeRegistered = false;
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Send passwordChangedNotice() on every auth:passwordChanged. Once, at boot -
|
|
497
|
+
* not on import, or every test that changes a password would send mail.
|
|
498
|
+
*
|
|
499
|
+
* @param {{log?: object}} [opts]
|
|
500
|
+
*/
|
|
501
|
+
export function registerPasswordChangeNotice({log = console} = {}) {
|
|
502
|
+
if (noticeRegistered) return;
|
|
503
|
+
noticeRegistered = true;
|
|
504
|
+
hooks.on('auth:passwordChanged', (e) => {
|
|
505
|
+
passwordChangedNotice(e || {}).catch(err => log.warn?.(`[auth] password-changed email not sent: ${err.message}`));
|
|
506
|
+
});
|
|
507
|
+
}
|
|
@@ -38,6 +38,7 @@ import {authenticateFor} from './pluginScope.js';
|
|
|
38
38
|
import {getEffectiveRoles} from './userRoles.js';
|
|
39
39
|
import * as defaultUsersService from './users.js';
|
|
40
40
|
import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
|
|
41
|
+
import {CORE_TOOLS, getToolStates, unmetRequirements} from './tools.js';
|
|
41
42
|
|
|
42
43
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
43
44
|
|
|
@@ -107,6 +108,9 @@ export function getPluginLoadFailures() {
|
|
|
107
108
|
*/
|
|
108
109
|
const _loadedPlugins = {};
|
|
109
110
|
|
|
111
|
+
/** Enabled plugins not loaded because a requirement is not running: name -> requirement. */
|
|
112
|
+
let _blockedPlugins = new Map();
|
|
113
|
+
|
|
110
114
|
/**
|
|
111
115
|
* Entitlement state per licensed plugin, as of the last registerPlugins().
|
|
112
116
|
* Reported by GET /api/plugins so the screen can explain an absence rather than
|
|
@@ -447,11 +451,26 @@ export async function registerPlugins(fastify) {
|
|
|
447
451
|
warn: message => fastify.log.warn(`[plugins] ${message}`)
|
|
448
452
|
});
|
|
449
453
|
|
|
454
|
+
// A plugin whose requirement is not running - a built-in Tool switched
|
|
455
|
+
// off, or a required plugin that is off, unlicensed or superseded - is not
|
|
456
|
+
// loaded. The switch screens prevent this state; this is the backstop for a
|
|
457
|
+
// hand-edited plugins.json or tools.json, so nothing runs half-wired.
|
|
458
|
+
const blocked = unmetRequirements(manifests,
|
|
459
|
+
new Set([...activeNames].filter(n => !superseded.has(n))));
|
|
460
|
+
_blockedPlugins = blocked;
|
|
461
|
+
|
|
450
462
|
const loaded = [];
|
|
451
463
|
for (const manifest of manifests) {
|
|
452
464
|
const state = states[manifest.name] || {};
|
|
453
465
|
if (!state.enabled && !CORE_PLUGINS.has(manifest.name)) continue;
|
|
454
466
|
|
|
467
|
+
const missing = blocked.get(manifest.name);
|
|
468
|
+
if (missing) {
|
|
469
|
+
_loadedPlugins[manifest.name] = {enabled: false, publicEntry: null, blockedBy: missing};
|
|
470
|
+
fastify.log.warn(`[plugins] "${manifest.name}" not loaded: it requires "${missing}", which is not running.`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
455
474
|
const displacedBy = superseded.get(manifest.name);
|
|
456
475
|
if (displacedBy) {
|
|
457
476
|
// Recorded rather than merely skipped: the admin Plugins screen
|
|
@@ -1242,6 +1261,8 @@ export async function getAdminPluginConfig() {
|
|
|
1242
1261
|
for (const manifest of manifests) {
|
|
1243
1262
|
const state = states[manifest.name] || {};
|
|
1244
1263
|
if ((!state.enabled && !CORE_PLUGINS.has(manifest.name)) || !manifest.admin) continue;
|
|
1264
|
+
// Not loaded for want of a requirement: its screens would only 404.
|
|
1265
|
+
if (_blockedPlugins.has(manifest.name)) continue;
|
|
1245
1266
|
|
|
1246
1267
|
if (manifest.admin.sidebar) sidebar.push(...manifest.admin.sidebar);
|
|
1247
1268
|
if (manifest.admin.routes) routes.push(...manifest.admin.routes);
|
|
@@ -1303,5 +1324,10 @@ export async function getAdminPluginConfig() {
|
|
|
1303
1324
|
}
|
|
1304
1325
|
}
|
|
1305
1326
|
|
|
1306
|
-
|
|
1327
|
+
// Built-in Tools and whether each is on, so the admin can turn a link to a
|
|
1328
|
+
// switched-off one away (services/tools.js).
|
|
1329
|
+
const toolStates = getToolStates();
|
|
1330
|
+
const tools = Object.fromEntries(CORE_TOOLS.map(t => [t.name, {displayName: t.displayName, enabled: toolStates[t.name]?.enabled !== false}]));
|
|
1331
|
+
|
|
1332
|
+
return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers(), tools };
|
|
1307
1333
|
}
|
|
@@ -14,6 +14,7 @@ import {getProjectForPage} from './projects.js';
|
|
|
14
14
|
import {resolveContextMenusForPage} from './contextMenus.js';
|
|
15
15
|
import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig, resolveThemeClasses} from './themeSettings.js';
|
|
16
16
|
import {getSearchSettings} from './search.js';
|
|
17
|
+
import {isToolEnabled} from './tools.js';
|
|
17
18
|
|
|
18
19
|
const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
|
|
19
20
|
const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
|
|
@@ -80,8 +81,8 @@ const ANALYTICS_ASSET_V = '20260922-analytics';
|
|
|
80
81
|
/**
|
|
81
82
|
* The page-view beacon.
|
|
82
83
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
84
|
+
* Emitted while the Analytics Tool is on (it can be switched off since 0.88.0,
|
|
85
|
+
* config/tools.json - services/tools.js). What it counts is
|
|
85
86
|
* decided in the browser - Do-Not-Track, per-session dedup and preview renders
|
|
86
87
|
* all opt out there, where the facts are.
|
|
87
88
|
*
|
|
@@ -90,6 +91,8 @@ const ANALYTICS_ASSET_V = '20260922-analytics';
|
|
|
90
91
|
* @returns {string} the body tag
|
|
91
92
|
*/
|
|
92
93
|
function buildAnalyticsTag() {
|
|
94
|
+
// Unless the site has switched Analytics off (config/tools.json).
|
|
95
|
+
if (!isToolEnabled('analytics')) return '';
|
|
93
96
|
return `<script src="/public/js/analytics.js?v=${ANALYTICS_ASSET_V}"></script>`;
|
|
94
97
|
}
|
|
95
98
|
|