domma-cms 1.8.12 → 1.8.14
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/CHANGELOG.md +10 -0
- package/CLAUDE.md +30 -0
- package/admin/js/api.js +1 -1
- package/admin/js/views/plugin-code.js +3 -3
- package/admin/js/views/plugins.js +8 -8
- package/docs/DommaFeatures.md +2 -1
- package/docs/configuration.md +3 -1
- package/package.json +1 -1
- package/server/config.js +3 -2
- package/server/env.js +12 -0
- package/server/routes/api/collections.js +9 -0
- package/server/routes/api/plugin-marketplace.js +1 -2
- package/server/routes/api/plugins.js +27 -5
- package/server/routes/api/sidebar.js +3 -0
- package/server/routes/api/tools.js +20 -6
- package/server/server.js +15 -9
- package/server/services/adapters/FileAdapter.js +2 -1
- package/server/services/codePolicy.js +111 -0
- package/server/services/collections.js +2 -1
- package/server/services/components.js +3 -2
- package/server/services/connectionManager.js +8 -0
- package/server/services/content.js +1 -1
- package/server/services/contextMenus.js +2 -1
- package/server/services/forms.js +4 -3
- package/server/services/menus.js +2 -1
- package/server/services/pluginFiles.js +8 -1
- package/server/services/pluginInstaller.js +16 -13
- package/server/services/pluginScaffold.js +3 -0
- package/server/services/plugins.js +11 -5
- package/server/services/safePath.js +35 -0
- package/server/services/staffAccess.js +131 -0
- package/server/services/tools.js +44 -1
- package/server/services/versions.js +1 -1
- package/server/services/views.js +4 -3
|
@@ -45,10 +45,11 @@ import * as defaultRolesService from './roles.js';
|
|
|
45
45
|
import {getAdminScope, normaliseAdminScope} from './roles.js';
|
|
46
46
|
import {authenticateFor} from './pluginScope.js';
|
|
47
47
|
import {getEffectiveRoles} from './userRoles.js';
|
|
48
|
+
import {isDcmStaff} from './staffAccess.js';
|
|
48
49
|
import * as defaultUsersService from './users.js';
|
|
49
50
|
import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
|
|
50
51
|
import {invalidateTags as cacheInvalidateTags} from './cache/index.js';
|
|
51
|
-
import {CORE_TOOLS, getToolStates, unmetRequirements} from './tools.js';
|
|
52
|
+
import {CORE_TOOLS, getToolStates, toolOpenAt, unmetRequirements} from './tools.js';
|
|
52
53
|
|
|
53
54
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
54
55
|
|
|
@@ -1353,7 +1354,10 @@ export function viewSection(manifest, viewName) {
|
|
|
1353
1354
|
return text && text !== (manifest.displayName || manifest.name) ? text : '';
|
|
1354
1355
|
}
|
|
1355
1356
|
|
|
1356
|
-
|
|
1357
|
+
/**
|
|
1358
|
+
* @param {object} [viewer] request.user - a staff-only Tool (services/tools.js) is on for a staff session only
|
|
1359
|
+
*/
|
|
1360
|
+
export async function getAdminPluginConfig(viewer = null) {
|
|
1357
1361
|
const manifests = await discoverPlugins();
|
|
1358
1362
|
const states = getPluginStates();
|
|
1359
1363
|
|
|
@@ -1432,10 +1436,12 @@ export async function getAdminPluginConfig() {
|
|
|
1432
1436
|
}
|
|
1433
1437
|
}
|
|
1434
1438
|
|
|
1435
|
-
// Built-in Tools and whether each is on, so the admin can
|
|
1436
|
-
// switched-off one away (services/tools.js
|
|
1439
|
+
// Built-in Tools and whether each is on FOR THIS VIEWER, so the admin can
|
|
1440
|
+
// turn a link to a switched-off one away (services/tools.js; `staff: true`
|
|
1441
|
+
// keeps one open to the Manager's staff sessions).
|
|
1437
1442
|
const toolStates = getToolStates();
|
|
1438
|
-
const
|
|
1443
|
+
const staff = isDcmStaff(viewer);
|
|
1444
|
+
const tools = Object.fromEntries(CORE_TOOLS.map(t => [t.name, {displayName: t.displayName, enabled: toolOpenAt(toolStates[t.name], staff)}]));
|
|
1439
1445
|
|
|
1440
1446
|
return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers(), tools };
|
|
1441
1447
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One name, one file, inside one folder.
|
|
3
|
+
*
|
|
4
|
+
* Fastify DECODES route params, so `/api/forms/..%2F..%2Fconfig%2Fserver`
|
|
5
|
+
* arrives as the slug `../../config/server`. A store that builds its file as
|
|
6
|
+
* `path.join(DIR, `${slug}.json`)` then reads and writes `config/server.json` -
|
|
7
|
+
* which is how a site admin could rewrite the host's own settings (switching
|
|
8
|
+
* `requireSignedPlugins` off, unlocking a Tool) through an ordinary form save.
|
|
9
|
+
* Routes check slugs in most places; this is the check at the file, so a route
|
|
10
|
+
* that forgets is still safe.
|
|
11
|
+
*
|
|
12
|
+
* @module services/safePath
|
|
13
|
+
*/
|
|
14
|
+
import path from 'path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The file `<dir>/<name><ext>`, refusing any name that is not one plain path
|
|
18
|
+
* segment (a separator, `.`/`..`, a NUL, empty, or not a string).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} dir
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @param {string} [ext] e.g. '.json'; '' for a folder
|
|
23
|
+
* @returns {string}
|
|
24
|
+
* @throws {Error} statusCode 404 - to a caller, a name like that names nothing
|
|
25
|
+
*/
|
|
26
|
+
export function fileIn(dir, name, ext = '') {
|
|
27
|
+
const root = path.resolve(dir);
|
|
28
|
+
const ok = typeof name === 'string' && name !== '' && name !== '.' && name !== '..'
|
|
29
|
+
&& !/[\\/\0]/.test(name);
|
|
30
|
+
const file = ok ? path.join(root, `${name}${ext}`) : null;
|
|
31
|
+
if (!file || path.dirname(file) !== root) {
|
|
32
|
+
throw Object.assign(new Error(`Not found: ${String(name).slice(0, 80)}`), {statusCode: 404, code: 'ENOENT'});
|
|
33
|
+
}
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff sessions - the hosting company's staff (the people at the site's
|
|
3
|
+
* Domma CMS Manager), as opposed to anyone the site itself has made.
|
|
4
|
+
*
|
|
5
|
+
* A Tool switched off with `staff: true` (services/tools.js) is open to a
|
|
6
|
+
* staff session only. A site's own users - its owner included, though the
|
|
7
|
+
* owner holds the top role - never are: a staff session is something only the
|
|
8
|
+
* Manager can mint, never a role or a user field the site could give itself.
|
|
9
|
+
*
|
|
10
|
+
* ## How a staff session starts
|
|
11
|
+
*
|
|
12
|
+
* The Manager signs a one-time TICKET with the site's own JWT_SECRET (as its
|
|
13
|
+
* Data Manager already does for its loopback calls) and sends the staff
|
|
14
|
+
* member's browser to `GET /_staff/open?ticket=…`:
|
|
15
|
+
*
|
|
16
|
+
* {type: 'staff-ticket', dcmStaff: true, site: <SITE_SLUG>, by, jti, to, exp: +60 s}
|
|
17
|
+
*
|
|
18
|
+
* This route checks the signature and expiry, that `site` is this site's
|
|
19
|
+
* SITE_SLUG (set only by the Manager that forked it - an unmanaged site has
|
|
20
|
+
* none and refuses every ticket), that `to` is a screen it opens, and that
|
|
21
|
+
* the ticket has not been used; then it hands the browser a one-hour access
|
|
22
|
+
* token `{id: 'dcm-staff', role: <top role>, type: 'access', dcmStaff: true,
|
|
23
|
+
* site}` and opens the admin at `to`. No refresh token: the session ends.
|
|
24
|
+
*
|
|
25
|
+
* Anyone who can read the site's JWT_SECRET (shell access, or code running in
|
|
26
|
+
* the site's process - which includes a level-0 user installing an unsigned
|
|
27
|
+
* plugin or editing one, unless the site is signed-only - services/codePolicy.js,
|
|
28
|
+
* which hosted sites are) can mint either;
|
|
29
|
+
* that is true of every session. The session is the site's full top role for
|
|
30
|
+
* an hour (the Manager can already act as it), with no `sid` to revoke it.
|
|
31
|
+
*
|
|
32
|
+
* @module services/staffAccess
|
|
33
|
+
*/
|
|
34
|
+
import {getRoleHierarchy} from './roles.js';
|
|
35
|
+
|
|
36
|
+
/** Screens a staff ticket may open. */
|
|
37
|
+
export const STAFF_SCREENS = Object.freeze(['#/wordpress-import']);
|
|
38
|
+
export const STAFF_SESSION_SECONDS = 3600;
|
|
39
|
+
const STAFF_ID = 'dcm-staff';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this decoded token (request.user) a staff session for this site?
|
|
43
|
+
*
|
|
44
|
+
* @param {object|null|undefined} user
|
|
45
|
+
* @param {string|undefined} [siteSlug] process.env.SITE_SLUG - unset on an unmanaged site
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isDcmStaff(user, siteSlug = process.env.SITE_SLUG) {
|
|
49
|
+
return Boolean(siteSlug) && user?.type === 'access' && user?.dcmStaff === true && user?.site === siteSlug;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a request carries a staff session. Never answers: a missing or bad
|
|
54
|
+
* token is "no".
|
|
55
|
+
*
|
|
56
|
+
* @param {import('fastify').FastifyRequest} request
|
|
57
|
+
* @returns {Promise<boolean>}
|
|
58
|
+
*/
|
|
59
|
+
export async function isStaffRequest(request) {
|
|
60
|
+
try {
|
|
61
|
+
const decoded = await request.jwtVerify();
|
|
62
|
+
return isDcmStaff(decoded);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** jti -> expiry (ms): tickets already used. */
|
|
69
|
+
const used = new Map();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check a ticket. Pure apart from the used-ticket list.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} decoded the verified ticket payload
|
|
75
|
+
* @param {{siteSlug?: string, now?: number}} [opts]
|
|
76
|
+
* @returns {{ok: true, to: string, by: string} | {ok: false, error: string}}
|
|
77
|
+
*/
|
|
78
|
+
export function acceptTicket(decoded, {siteSlug = process.env.SITE_SLUG, now = Date.now()} = {}) {
|
|
79
|
+
for (const [jti, exp] of used) if (exp < now) used.delete(jti);
|
|
80
|
+
if (!siteSlug) return {ok: false, error: 'This site is not run by a Domma CMS Manager.'};
|
|
81
|
+
if (decoded?.type !== 'staff-ticket' || decoded.dcmStaff !== true) return {ok: false, error: 'Not a staff ticket.'};
|
|
82
|
+
if (decoded.site !== siteSlug) return {ok: false, error: 'This ticket is for another site.'};
|
|
83
|
+
if (!STAFF_SCREENS.includes(decoded.to)) return {ok: false, error: 'This ticket does not open anything here.'};
|
|
84
|
+
if (typeof decoded.jti !== 'string' || decoded.jti.length < 16) return {ok: false, error: 'Not a staff ticket.'};
|
|
85
|
+
if (used.has(decoded.jti)) return {ok: false, error: 'This ticket has been used - open it again from the manager.'};
|
|
86
|
+
used.set(decoded.jti, (Number(decoded.exp) || 0) * 1000 + 60_000);
|
|
87
|
+
return {ok: true, to: decoded.to, by: String(decoded.by || 'staff').slice(0, 120)};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
91
|
+
const page = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex">
|
|
92
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title></head><body>${body}</body></html>`;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* GET /_staff/open?ticket=… - exchange a Manager's ticket for a staff session.
|
|
96
|
+
*
|
|
97
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
98
|
+
*/
|
|
99
|
+
export async function staffAccessRoutes(fastify) {
|
|
100
|
+
fastify.get('/_staff/open', {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
101
|
+
reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer').header('X-Robots-Tag', 'noindex');
|
|
102
|
+
const refuse = (error) => reply.code(403).type('text/html; charset=utf-8')
|
|
103
|
+
.send(page('Not opened', `<p>${error.replace(/[<>&]/g, '')}</p>`));
|
|
104
|
+
const ticket = typeof request.query?.ticket === 'string' ? request.query.ticket : '';
|
|
105
|
+
if (!ticket || ticket.length > 4096) return refuse('Not a staff ticket.');
|
|
106
|
+
let decoded;
|
|
107
|
+
try {
|
|
108
|
+
decoded = fastify.jwt.verify(ticket);
|
|
109
|
+
} catch {
|
|
110
|
+
return refuse('This ticket is not valid or has run out - open it again from the manager.');
|
|
111
|
+
}
|
|
112
|
+
const ok = acceptTicket(decoded);
|
|
113
|
+
if (!ok.ok) return refuse(ok.error);
|
|
114
|
+
|
|
115
|
+
const role = getRoleHierarchy()[0];
|
|
116
|
+
if (!role) return refuse('This site has no roles yet.');
|
|
117
|
+
const user = {id: STAFF_ID, name: `${ok.by} (staff)`, email: '', role, additionalRoles: [], level: 0, dcmStaff: true};
|
|
118
|
+
const token = fastify.jwt.sign({id: STAFF_ID, role, type: 'access', dcmStaff: true, site: decoded.site, name: user.name},
|
|
119
|
+
{expiresIn: STAFF_SESSION_SECONDS});
|
|
120
|
+
// warn, not info: production logs at warn, and this is the site's only record of a staff visit.
|
|
121
|
+
fastify.log.warn?.(`[staff] ${ok.by} opened a staff session at ${ok.to} (ticket ${String(decoded.jti).slice(0, 8)}…)`);
|
|
122
|
+
return reply.type('text/html; charset=utf-8').send(page('Opening…', `<p>Opening…</p>
|
|
123
|
+
<script src="/dist/domma/domma.min.js"></script>
|
|
124
|
+
<script>
|
|
125
|
+
S.remove('auth_refresh_token');
|
|
126
|
+
S.set('auth_token', ${json(token)});
|
|
127
|
+
S.set('auth_user', ${json(user)});
|
|
128
|
+
location.replace('/admin/' + ${json(ok.to)});
|
|
129
|
+
</script>`));
|
|
130
|
+
});
|
|
131
|
+
}
|
package/server/services/tools.js
CHANGED
|
@@ -39,6 +39,19 @@
|
|
|
39
39
|
* own admin cannot change that switch; only its Manager, which writes the
|
|
40
40
|
* files directly. A cascade that would move a locked switch is refused.
|
|
41
41
|
*
|
|
42
|
+
* ## Off for the site, on for the hosting staff (`staff`)
|
|
43
|
+
*
|
|
44
|
+
* { "wordpress-import": { "enabled": false, "locked": true, "staff": true } }
|
|
45
|
+
*
|
|
46
|
+
* `staff: true` on a Tool that is off keeps it open to a STAFF SESSION - one
|
|
47
|
+
* the site's Manager minted (services/staffAccess.js, `dcmStaff`) - and to
|
|
48
|
+
* nobody else, not even the site's own top role: its API answers a staff
|
|
49
|
+
* session (toolGate), its sidebar entry is shown to one only, and the admin
|
|
50
|
+
* route lets one in. Everyone else sees an ordinary switched-off Tool. The
|
|
51
|
+
* Manager writes it - hosted sites get the WordPress importer this way until
|
|
52
|
+
* it is gifted. It changes nothing the planner decides: a staff-only Tool is
|
|
53
|
+
* OFF to planSwitch().
|
|
54
|
+
*
|
|
42
55
|
* @module services/tools
|
|
43
56
|
*/
|
|
44
57
|
import {getConfig, saveConfig} from '../config.js';
|
|
@@ -84,6 +97,35 @@ export function isToolLocked(name) {
|
|
|
84
97
|
return getToolStates()[name]?.locked === true;
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether a Tool in this stored state is open to a viewer. Pure: on for
|
|
102
|
+
* everyone unless switched off; off with `staff: true` is still on for a
|
|
103
|
+
* staff session (services/staffAccess.js isDcmStaff) - and for nobody else.
|
|
104
|
+
*
|
|
105
|
+
* @param {{enabled?: boolean, staff?: boolean}|undefined} state
|
|
106
|
+
* @param {boolean} staffSession isDcmStaff(request.user)
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function toolOpenAt(state, staffSession) {
|
|
110
|
+
if (state?.enabled !== false) return true;
|
|
111
|
+
return state?.staff === true && staffSession === true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {string} name @returns {boolean} off for the site, kept open to staff sessions (`staff`) */
|
|
115
|
+
export function isToolStaffOnly(name) {
|
|
116
|
+
const s = getToolStates()[name];
|
|
117
|
+
return s?.enabled === false && s?.staff === true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @param {boolean} staffSession isDcmStaff(request.user)
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export function isToolOpenTo(name, staffSession) {
|
|
126
|
+
return toolOpenAt(getToolStates()[name], staffSession);
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
/**
|
|
88
130
|
* @param {string} name
|
|
89
131
|
* @param {{enabled?: boolean, locked?: boolean}} patch
|
|
@@ -121,7 +163,8 @@ export function switchableUnits({manifests = [], pluginStates = {}, toolStates =
|
|
|
121
163
|
for (const t of CORE_TOOLS) {
|
|
122
164
|
const s = toolStates[t.name] || {};
|
|
123
165
|
units.set(t.name, {name: t.name, kind: 'tool', displayName: t.displayName, icon: t.icon,
|
|
124
|
-
enabled: s.enabled !== false, locked: s.locked === true,
|
|
166
|
+
enabled: s.enabled !== false, locked: s.locked === true, staff: s.enabled === false && s.staff === true,
|
|
167
|
+
requires: [], uses: {}, supersedes: [], closedSource: false});
|
|
125
168
|
}
|
|
126
169
|
for (const m of manifests) {
|
|
127
170
|
if (!m?.name || units.has(m.name)) continue;
|
|
@@ -37,7 +37,7 @@ const PAGES_DIR = path.join(config.content.contentDir, 'pages');
|
|
|
37
37
|
function versionsDirForPath(urlPath) {
|
|
38
38
|
const normalised = urlPath === '/' || !urlPath ? '_index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
39
39
|
const resolved = path.resolve(path.join(VERSIONS_DIR, normalised));
|
|
40
|
-
if (!resolved.startsWith(path.resolve(VERSIONS_DIR))) {
|
|
40
|
+
if (resolved !== path.resolve(VERSIONS_DIR) && !resolved.startsWith(path.resolve(VERSIONS_DIR) + path.sep)) {
|
|
41
41
|
throw new Error('Invalid path: outside versions directory');
|
|
42
42
|
}
|
|
43
43
|
return resolved;
|
package/server/services/views.js
CHANGED
|
@@ -34,6 +34,7 @@ import {checkVisibility} from '../middleware/auth.js';
|
|
|
34
34
|
import {getEffectiveLevel} from './userRoles.js';
|
|
35
35
|
import {executePipeline} from './viewPipeline.js';
|
|
36
36
|
import * as cache from './cache/index.js';
|
|
37
|
+
import {fileIn} from './safePath.js';
|
|
37
38
|
|
|
38
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
40
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -122,7 +123,7 @@ export async function ensureViewsDir() {
|
|
|
122
123
|
* @throws {Error} If the view file does not exist or cannot be parsed.
|
|
123
124
|
*/
|
|
124
125
|
export async function readView(slug) {
|
|
125
|
-
const file =
|
|
126
|
+
const file = fileIn(VIEWS_DIR, slug, '.json');
|
|
126
127
|
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -135,7 +136,7 @@ export async function readView(slug) {
|
|
|
135
136
|
*/
|
|
136
137
|
export async function writeView(slug, view) {
|
|
137
138
|
await ensureViewsDir();
|
|
138
|
-
const file =
|
|
139
|
+
const file = fileIn(VIEWS_DIR, slug, '.json');
|
|
139
140
|
await fs.writeFile(file, JSON.stringify(view, null, 4) + '\n', 'utf8');
|
|
140
141
|
await cache.invalidateTags([`view:${slug}`]);
|
|
141
142
|
}
|
|
@@ -368,7 +369,7 @@ export async function deleteView(slug) {
|
|
|
368
369
|
throw new Error(`View "${slug}" not found`);
|
|
369
370
|
}
|
|
370
371
|
try {
|
|
371
|
-
await fs.unlink(
|
|
372
|
+
await fs.unlink(fileIn(VIEWS_DIR, slug, '.json'));
|
|
372
373
|
} catch {
|
|
373
374
|
throw new Error(`View "${slug}" not found`);
|
|
374
375
|
}
|