domma-cms 0.87.0 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +33 -5
- 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 +2 -2
- 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 +4 -4
- package/plugins/mail-reader/CLAUDE.md +5 -0
- package/plugins/mail-reader/plugin.json +5 -2
- package/server/routes/api/auth.js +41 -63
- 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/tools.js +259 -0
- package/server/services/users.js +8 -0
|
@@ -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
|
}
|