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
|
@@ -84,9 +84,114 @@ export const PRIVATE_DATA = [
|
|
|
84
84
|
'contacts', 'contacts-contacts', 'contacts-groups', 'roles', 'user-profiles',
|
|
85
85
|
'user-messages', 'user-message-threads', 'analytics-events', 'enquiries',
|
|
86
86
|
'blog-comments', 'invoices', 'invoice-receivers', 'invoice-issuers',
|
|
87
|
-
'surveys-contacts', 'surveys-invites'
|
|
87
|
+
'surveys-contacts', 'surveys-invites',
|
|
88
|
+
// Paid plugins' visitor and customer records (0.88.1): Cart Pro's orders and
|
|
89
|
+
// discount codes, Calendar's RSVPs, the job board's people and applications.
|
|
90
|
+
'shop-orders', 'shop-pro-codes', 'calendar-rsvps',
|
|
91
|
+
'jb-applications', 'jb-candidate-profiles', 'jb-signups'
|
|
88
92
|
];
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The collection a form stores its submissions in - the same choice as the
|
|
96
|
+
* submit route (routes/api/forms.js): the collection action's slug when it is
|
|
97
|
+
* on, otherwise the form's own slug. An action slug set but switched off is
|
|
98
|
+
* kept out too: ensureCollectionForForm provisions into it.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} form - Parsed content/forms/<slug>.json
|
|
101
|
+
* @returns {string[]} collection slugs
|
|
102
|
+
*/
|
|
103
|
+
export function formDataCollections(form) {
|
|
104
|
+
const action = form?.actions?.collection;
|
|
105
|
+
const safe = (v) => (typeof v === 'string' && /^[a-z0-9][a-z0-9_-]*$/i.test(v) ? v : '');
|
|
106
|
+
return [...new Set([safe(action?.slug), safe(form?.slug)].filter(Boolean))];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `[form name="x" /]` (or slug="x") anywhere in a text. */
|
|
110
|
+
function embeddedFormNames(text) {
|
|
111
|
+
const out = new Set();
|
|
112
|
+
for (const m of String(text).matchAll(/\[form\b([^\]]*)\]/gi)) {
|
|
113
|
+
const a = m[1].match(/\b(?:name|slug)\s*=\s*["']?([A-Za-z0-9_-]+)/);
|
|
114
|
+
if (a) out.add(a[1]);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readTree(dir, exts, out = [], depth = 0) {
|
|
120
|
+
if (depth > 6) return out;
|
|
121
|
+
let entries = [];
|
|
122
|
+
try { entries = fs.readdirSync(dir, {withFileTypes: true}); } catch { return out; }
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
const full = path.join(dir, e.name);
|
|
125
|
+
if (e.isDirectory()) { if (e.name !== 'media' && e.name !== 'collections') readTree(full, exts, out, depth + 1); }
|
|
126
|
+
else if (exts.some(x => e.name.endsWith(x))) out.push(full);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The collections this site's VISITORS write to, from what is on disk - like
|
|
133
|
+
* PRIVATE_DATA, but only the site knows which they are. Before 0.88.1 nothing
|
|
134
|
+
* did this: a form storing in `find-a-tutor` had every enquiry - names, emails,
|
|
135
|
+
* phone numbers - committed to the site repo.
|
|
136
|
+
*
|
|
137
|
+
* "A form writes there" is not enough: every collection created in the admin
|
|
138
|
+
* gets a form of its own (ensureFormForCollection), and a content collection
|
|
139
|
+
* (FAQs, testimonials) must stay tracked. So a collection counts when
|
|
140
|
+
* - a form storing in it is EMBEDDED somewhere in content ([form name=...]), or
|
|
141
|
+
* - it was made by a form (schema meta.generatedFromForm).
|
|
142
|
+
* The submit route adds the rest the moment a visitor's entry is stored
|
|
143
|
+
* (keepFormDataOut) - the one signal that is never wrong.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} siteRoot
|
|
146
|
+
* @returns {string[]} sorted, de-duplicated
|
|
147
|
+
*/
|
|
148
|
+
export function siteFormDataCollections(siteRoot) {
|
|
149
|
+
const content = path.join(siteRoot, 'content');
|
|
150
|
+
const embedded = new Set();
|
|
151
|
+
for (const f of readTree(content, ['.md', '.html', '.json'])) {
|
|
152
|
+
if (f.startsWith(path.join(content, 'forms') + path.sep)) continue;
|
|
153
|
+
try { for (const n of embeddedFormNames(fs.readFileSync(f, 'utf8'))) embedded.add(n); } catch { /* skip */ }
|
|
154
|
+
}
|
|
155
|
+
const out = new Set();
|
|
156
|
+
const formsDir = path.join(content, 'forms');
|
|
157
|
+
let files = [];
|
|
158
|
+
try { files = fs.readdirSync(formsDir).filter(f => f.endsWith('.json')); } catch { /* no forms */ }
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
try {
|
|
161
|
+
const form = JSON.parse(fs.readFileSync(path.join(formsDir, f), 'utf8'));
|
|
162
|
+
if (embedded.has(form.slug) || embedded.has(f.replace(/\.json$/, ''))) for (const c of formDataCollections(form)) out.add(c);
|
|
163
|
+
} catch { /* an unreadable form keeps nothing out - and breaks nothing */ }
|
|
164
|
+
}
|
|
165
|
+
const colDir = path.join(content, 'collections');
|
|
166
|
+
let cols = [];
|
|
167
|
+
try { cols = fs.readdirSync(colDir); } catch { /* none */ }
|
|
168
|
+
for (const c of cols) {
|
|
169
|
+
try {
|
|
170
|
+
const schema = JSON.parse(fs.readFileSync(path.join(colDir, c, 'schema.json'), 'utf8'));
|
|
171
|
+
if (schema?.meta?.generatedFromForm) out.add(c);
|
|
172
|
+
} catch { /* no schema */ }
|
|
173
|
+
}
|
|
174
|
+
return [...out].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rule that keeps one collection's entries out. */
|
|
178
|
+
export const collectionDataRule = (slug) => `/content/collections/${slug}/data.json`;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Keep a collection a visitor has just written to out of a managed .gitignore -
|
|
182
|
+
* called by the submit route once an entry is stored, so submissions never wait
|
|
183
|
+
* for the next regeneration to stop being committed. Same guard as
|
|
184
|
+
* allowInGitignore: a missing or hand-written file is left alone.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} siteRoot
|
|
187
|
+
* @param {string} slug - The collection the entry went into
|
|
188
|
+
* @returns {string[]} The rules actually added
|
|
189
|
+
*/
|
|
190
|
+
export function keepSubmissionsOut(siteRoot, slug) {
|
|
191
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(String(slug || '')) || PRIVATE_DATA.includes(slug)) return [];
|
|
192
|
+
return allowInGitignore(siteRoot, [collectionDataRule(slug)]);
|
|
193
|
+
}
|
|
194
|
+
|
|
90
195
|
/**
|
|
91
196
|
* Lines bin/update.js appends when it cannot find them VERBATIM. They are
|
|
92
197
|
* already ignored by `/*` and `/plugins/*`, but writing them out keeps the
|
|
@@ -151,10 +256,11 @@ export function pluginContentRules(name, rel) {
|
|
|
151
256
|
* @param {Object<string,string[]>} [opts.pluginContent={}] - Extra content folders in distributed plugins
|
|
152
257
|
* @param {string[]} [opts.sitePublic=[]] - Site-added entries directly under public/
|
|
153
258
|
* @param {string[]} [opts.extras=[]] - Other site-owned top-level entries (docs, tools, README.md)
|
|
259
|
+
* @param {string[]} [opts.formData=[]] - Collections the site's forms store submissions in (siteFormDataCollections)
|
|
154
260
|
* @param {string[]} [opts.siteRules=[]] - The site-specific section
|
|
155
261
|
* @returns {string}
|
|
156
262
|
*/
|
|
157
|
-
export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic = [], extras = [], siteRules = []} = {}) {
|
|
263
|
+
export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic = [], extras = [], formData = [], siteRules = []} = {}) {
|
|
158
264
|
const L = [
|
|
159
265
|
MANAGED_HEADER,
|
|
160
266
|
'# This repo holds the site\'s CONTENT only. Everything is ignored, then what',
|
|
@@ -192,7 +298,12 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
|
|
|
192
298
|
L.push('', '# Schemas the CMS rewrites on every boot - engine-owned, not authored');
|
|
193
299
|
for (const c of ENGINE_SCHEMAS) L.push(`/content/collections/${c}/schema.json`);
|
|
194
300
|
L.push('', '# Secrets, visitor-submitted and per-user data, runtime state');
|
|
195
|
-
for (const c of PRIVATE_DATA) L.push(
|
|
301
|
+
for (const c of PRIVATE_DATA) L.push(collectionDataRule(c));
|
|
302
|
+
const submitted = [...new Set(formData)].filter(c => !PRIVATE_DATA.includes(c)).sort();
|
|
303
|
+
if (submitted.length) {
|
|
304
|
+
L.push('', '# Form submissions - what visitors sent (siteFormDataCollections)');
|
|
305
|
+
for (const c of submitted) L.push(collectionDataRule(c));
|
|
306
|
+
}
|
|
196
307
|
L.push(
|
|
197
308
|
'/content/users/',
|
|
198
309
|
'/content/sessions/',
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switching Tools on and off - the built-in ones and plugins alike.
|
|
3
|
+
*
|
|
4
|
+
* ## Built-in Tools
|
|
5
|
+
*
|
|
6
|
+
* Contacts, Notes, Todo and Analytics were plugins until 0.67.0. They are part
|
|
7
|
+
* of the CMS now and ON unless a site says otherwise, in `config/tools.json`:
|
|
8
|
+
*
|
|
9
|
+
* { "notes": { "enabled": false, "locked": true } }
|
|
10
|
+
*
|
|
11
|
+
* A Tool that is off keeps its data. Its API answers 404 (server.js gates the
|
|
12
|
+
* prefix at request time), its sidebar entry is not registered, and anything
|
|
13
|
+
* it feeds - notification sources, calendar sources, related items, the
|
|
14
|
+
* analytics beacon - is left out.
|
|
15
|
+
*
|
|
16
|
+
* ## Dependencies
|
|
17
|
+
*
|
|
18
|
+
* A plugin's manifest says what it needs:
|
|
19
|
+
*
|
|
20
|
+
* "requires": ["contacts"] - cannot work without it
|
|
21
|
+
* "uses": {"contacts": "Invite a contact group"} - loses that feature without it
|
|
22
|
+
*
|
|
23
|
+
* Either may name a built-in Tool or another plugin. `supersedes` is a
|
|
24
|
+
* different relationship (a replacement) and is not a dependency.
|
|
25
|
+
*
|
|
26
|
+
* `planSwitch()` is the one place that decides what a change does: which
|
|
27
|
+
* plugins go off with a Tool they require, which requirements come on with a
|
|
28
|
+
* plugin, what a soft dependant loses, and what is refused. It is pure so the
|
|
29
|
+
* Manager can run it against a site on disk before writing anything, and the
|
|
30
|
+
* site runs the same function when its own admin flips a switch. The loader
|
|
31
|
+
* (`unmetRequirements()`) is the backstop: a plugin whose requirement is off
|
|
32
|
+
* is not loaded, whatever plugins.json says.
|
|
33
|
+
*
|
|
34
|
+
* ## Locks
|
|
35
|
+
*
|
|
36
|
+
* `locked: true` - on a tools.json or a plugins.json entry - means the site's
|
|
37
|
+
* own admin cannot change that switch; only its Manager, which writes the
|
|
38
|
+
* files directly. A cascade that would move a locked switch is refused.
|
|
39
|
+
*
|
|
40
|
+
* @module services/tools
|
|
41
|
+
*/
|
|
42
|
+
import {getConfig, saveConfig} from '../config.js';
|
|
43
|
+
|
|
44
|
+
/** The built-in Tools that can be switched off. Order is display order. */
|
|
45
|
+
export const CORE_TOOLS = Object.freeze([
|
|
46
|
+
{name: 'contacts', displayName: 'Contacts', icon: 'users', description: 'An address book for each user, with groups.'},
|
|
47
|
+
{name: 'notes', displayName: 'Notes', icon: 'edit-3', description: 'Private notes for each user.'},
|
|
48
|
+
{name: 'todo', displayName: 'Todo', icon: 'check-square', description: 'To-do lists with due dates and reminders.'},
|
|
49
|
+
{name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'}
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const CORE_NAMES = new Set(CORE_TOOLS.map(t => t.name));
|
|
53
|
+
|
|
54
|
+
/** @param {string} name @returns {boolean} */
|
|
55
|
+
export const isCoreTool = (name) => CORE_NAMES.has(name);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stored state of the built-in Tools. Missing file or entry = on, unlocked.
|
|
59
|
+
* Read fresh each call: the Manager writes this file from outside the process.
|
|
60
|
+
*
|
|
61
|
+
* @returns {Object.<string, {enabled?: boolean, locked?: boolean}>}
|
|
62
|
+
*/
|
|
63
|
+
export function getToolStates() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = getConfig('tools');
|
|
66
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {string} name @returns {boolean} a built-in Tool is on unless tools.json says otherwise */
|
|
73
|
+
export function isToolEnabled(name) {
|
|
74
|
+
return getToolStates()[name]?.enabled !== false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string} name @returns {boolean} */
|
|
78
|
+
export function isToolLocked(name) {
|
|
79
|
+
return getToolStates()[name]?.locked === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} name
|
|
84
|
+
* @param {{enabled?: boolean, locked?: boolean}} patch
|
|
85
|
+
*/
|
|
86
|
+
export function saveToolState(name, patch) {
|
|
87
|
+
if (!isCoreTool(name)) throw new Error(`"${name}" is not a built-in Tool`);
|
|
88
|
+
const states = getToolStates();
|
|
89
|
+
states[name] = {...states[name], ...patch};
|
|
90
|
+
saveConfig('tools', states);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `requires` from a manifest, as a clean list of names. */
|
|
94
|
+
export function requiresOf(manifest) {
|
|
95
|
+
const r = manifest?.requires;
|
|
96
|
+
return Array.isArray(r) ? r.filter(n => typeof n === 'string' && n) : [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `uses` from a manifest, as {name: reason}. An array form is accepted too. */
|
|
100
|
+
export function usesOf(manifest) {
|
|
101
|
+
const u = manifest?.uses;
|
|
102
|
+
if (Array.isArray(u)) return Object.fromEntries(u.filter(n => typeof n === 'string').map(n => [n, '']));
|
|
103
|
+
if (!u || typeof u !== 'object') return {};
|
|
104
|
+
return Object.fromEntries(Object.entries(u).map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Everything that can be switched, as one list the planner reads.
|
|
109
|
+
*
|
|
110
|
+
* @param {{manifests?: object[], pluginStates?: object, toolStates?: object}} input
|
|
111
|
+
* @returns {Map<string, {name: string, kind: 'tool'|'plugin', displayName: string, enabled: boolean,
|
|
112
|
+
* locked: boolean, requires: string[], uses: Object.<string,string>, supersedes: string[], closedSource: boolean}>}
|
|
113
|
+
*/
|
|
114
|
+
export function switchableUnits({manifests = [], pluginStates = {}, toolStates = {}} = {}) {
|
|
115
|
+
const units = new Map();
|
|
116
|
+
for (const t of CORE_TOOLS) {
|
|
117
|
+
const s = toolStates[t.name] || {};
|
|
118
|
+
units.set(t.name, {name: t.name, kind: 'tool', displayName: t.displayName, icon: t.icon,
|
|
119
|
+
enabled: s.enabled !== false, locked: s.locked === true, requires: [], uses: {}, supersedes: [], closedSource: false});
|
|
120
|
+
}
|
|
121
|
+
for (const m of manifests) {
|
|
122
|
+
if (!m?.name || units.has(m.name)) continue;
|
|
123
|
+
const s = pluginStates[m.name] || {};
|
|
124
|
+
units.set(m.name, {name: m.name, kind: 'plugin', displayName: m.displayName || m.name, icon: m.icon || 'package',
|
|
125
|
+
enabled: s.enabled === true, locked: s.locked === true, requires: requiresOf(m), uses: usesOf(m),
|
|
126
|
+
supersedes: Array.isArray(m.supersedes) ? m.supersedes : [], closedSource: m.closedSource === true});
|
|
127
|
+
}
|
|
128
|
+
return units;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The name as people read it. */
|
|
132
|
+
const label = (units, name) => units.get(name)?.displayName || name;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What a set of switch changes would do.
|
|
136
|
+
*
|
|
137
|
+
* - Switching off something an enabled plugin REQUIRES switches that plugin
|
|
138
|
+
* off too (a cascade), recursively.
|
|
139
|
+
* - Switching on a plugin switches on what it requires, recursively.
|
|
140
|
+
* - A soft dependant (`uses`) stays on and the feature it loses is listed.
|
|
141
|
+
* - A locked switch cannot be moved unless `asManager`; a request or cascade
|
|
142
|
+
* that would move one is refused.
|
|
143
|
+
* - A requirement that is not installed at all refuses the plugin.
|
|
144
|
+
*
|
|
145
|
+
* Only dependencies touched by THIS change are followed: a plugin that was
|
|
146
|
+
* already enabled with a requirement off (a broken state the loader refuses
|
|
147
|
+
* to run) is left as it is.
|
|
148
|
+
*
|
|
149
|
+
* @param {Map} units from switchableUnits()
|
|
150
|
+
* @param {Object.<string, boolean>} requested name -> on/off
|
|
151
|
+
* @param {{asManager?: boolean}} [opts]
|
|
152
|
+
* @returns {{ok: boolean, changes: Object.<string, boolean>, cascaded: object[], losses: object[], refused: object[]}}
|
|
153
|
+
*/
|
|
154
|
+
export function planSwitch(units, requested = {}, {asManager = false} = {}) {
|
|
155
|
+
const next = new Map([...units].map(([n, u]) => [n, u.enabled]));
|
|
156
|
+
const was = (n) => units.get(n)?.enabled === true;
|
|
157
|
+
const may = (n) => asManager || !units.get(n)?.locked;
|
|
158
|
+
const refused = [];
|
|
159
|
+
const cascaded = [];
|
|
160
|
+
const cascadeOn = new Set();
|
|
161
|
+
|
|
162
|
+
for (const [name, on] of Object.entries(requested)) {
|
|
163
|
+
const u = units.get(name);
|
|
164
|
+
if (!u) { refused.push({name, reason: 'is not installed on this site'}); continue; }
|
|
165
|
+
if (!!on === u.enabled) continue;
|
|
166
|
+
if (!may(name)) { refused.push({name, reason: 'is locked by the site\'s manager'}); continue; }
|
|
167
|
+
next.set(name, !!on);
|
|
168
|
+
}
|
|
169
|
+
const askedOn = (n) => requested[n] === true || cascadeOn.has(n);
|
|
170
|
+
|
|
171
|
+
// Settle to a fixed point: each pass may switch something that changes
|
|
172
|
+
// what another needs. Bounded - each unit changes direction at most once.
|
|
173
|
+
for (let pass = 0, changed = true; changed && pass <= units.size + 1; pass++) {
|
|
174
|
+
changed = false;
|
|
175
|
+
for (const [name, u] of units) {
|
|
176
|
+
if (next.get(name) !== true) continue;
|
|
177
|
+
for (const r of u.requires) {
|
|
178
|
+
if (next.get(r) === true) continue;
|
|
179
|
+
const turningOn = !was(name);
|
|
180
|
+
// Only follow a dependency this change touches.
|
|
181
|
+
if (!turningOn && !was(r)) continue;
|
|
182
|
+
if (turningOn && askedOn(name)) {
|
|
183
|
+
const ru = units.get(r);
|
|
184
|
+
if (!ru) {
|
|
185
|
+
refused.push({name, reason: `needs ${r}, which is not installed`});
|
|
186
|
+
} else if (requested[r] === false) {
|
|
187
|
+
refused.push({name, reason: `needs ${label(units, r)}, which is being switched off`});
|
|
188
|
+
} else if (!may(r)) {
|
|
189
|
+
refused.push({name, reason: `needs ${label(units, r)}, which is locked off`});
|
|
190
|
+
} else {
|
|
191
|
+
next.set(r, true);
|
|
192
|
+
cascadeOn.add(r);
|
|
193
|
+
cascaded.push({name: r, displayName: label(units, r), enabled: true, because: name, becauseName: label(units, name)});
|
|
194
|
+
changed = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
next.set(name, false);
|
|
198
|
+
changed = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
// r is going off (or never came on): name has to follow it.
|
|
202
|
+
if (!may(name)) {
|
|
203
|
+
refused.push({name: r, reason: `${label(units, name)} needs it and is locked on`});
|
|
204
|
+
next.set(r, true);
|
|
205
|
+
} else {
|
|
206
|
+
next.set(name, false);
|
|
207
|
+
cascaded.push({name, displayName: label(units, name), enabled: false, because: r, becauseName: label(units, r)});
|
|
208
|
+
}
|
|
209
|
+
changed = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A cascade may have been undone by a later refusal; keep only real moves.
|
|
216
|
+
const changes = {};
|
|
217
|
+
for (const [n, on] of next) if (on !== was(n)) changes[n] = on;
|
|
218
|
+
const real = cascaded.filter(c => changes[c.name] === c.enabled);
|
|
219
|
+
|
|
220
|
+
const losses = [];
|
|
221
|
+
for (const [name, u] of units) {
|
|
222
|
+
if (next.get(name) !== true) continue;
|
|
223
|
+
for (const [t, reason] of Object.entries(u.uses)) {
|
|
224
|
+
if (was(t) && next.get(t) === false) {
|
|
225
|
+
losses.push({name, displayName: u.displayName, tool: t, toolName: label(units, t), reason});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {ok: refused.length === 0, changes, cascaded: real, losses, refused};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Which enabled plugins cannot run because something they require is not
|
|
235
|
+
* running. `active` is the set that would load (after entitlement and
|
|
236
|
+
* supersession); built-in Tools are active when switched on.
|
|
237
|
+
*
|
|
238
|
+
* @param {object[]} manifests
|
|
239
|
+
* @param {Set<string>} active plugin names that would otherwise load
|
|
240
|
+
* @param {(name: string) => boolean} toolOn
|
|
241
|
+
* @returns {Map<string, string>} plugin -> the requirement it is missing
|
|
242
|
+
*/
|
|
243
|
+
export function unmetRequirements(manifests, active, toolOn = isToolEnabled) {
|
|
244
|
+
const byName = new Map(manifests.map(m => [m.name, m]));
|
|
245
|
+
const running = new Set(active);
|
|
246
|
+
const blocked = new Map();
|
|
247
|
+
for (let changed = true; changed;) {
|
|
248
|
+
changed = false;
|
|
249
|
+
for (const name of running) {
|
|
250
|
+
const missing = requiresOf(byName.get(name)).find(r => (isCoreTool(r) ? !toolOn(r) : !running.has(r)));
|
|
251
|
+
if (missing) {
|
|
252
|
+
running.delete(name);
|
|
253
|
+
blocked.set(name, missing);
|
|
254
|
+
changed = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return blocked;
|
|
259
|
+
}
|
package/server/services/users.js
CHANGED
|
@@ -28,6 +28,8 @@ async function ensureDir() {
|
|
|
28
28
|
*/
|
|
29
29
|
function stripPassword(user) {
|
|
30
30
|
const {password, resetTokenHash, resetTokenExpiry, ...safe} = user;
|
|
31
|
+
// When an unused reset link is still open - the time only, never the token.
|
|
32
|
+
if (resetTokenHash && Date.parse(resetTokenExpiry) > Date.now()) safe.resetLinkExpires = resetTokenExpiry;
|
|
31
33
|
return safe;
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -182,6 +184,12 @@ export async function updateUser(id, updates) {
|
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
// Reset tokens are written by setResetToken/clearResetToken only - never by an
|
|
188
|
+
// update, which takes whole records from clients.
|
|
189
|
+
delete updates.resetTokenHash;
|
|
190
|
+
delete updates.resetTokenExpiry;
|
|
191
|
+
delete updates.resetLinkExpires;
|
|
192
|
+
|
|
185
193
|
if (updates.password) {
|
|
186
194
|
updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
|
|
187
195
|
}
|