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.
@@ -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
- export async function getAdminPluginConfig() {
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 turn a link to a
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 tools = Object.fromEntries(CORE_TOOLS.map(t => [t.name, {displayName: t.displayName, enabled: toolStates[t.name]?.enabled !== false}]));
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
+ }
@@ -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, requires: [], uses: {}, supersedes: [], closedSource: false});
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;
@@ -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 = path.join(VIEWS_DIR, `${slug}.json`);
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 = path.join(VIEWS_DIR, `${slug}.json`);
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(path.join(VIEWS_DIR, `${slug}.json`));
372
+ await fs.unlink(fileIn(VIEWS_DIR, slug, '.json'));
372
373
  } catch {
373
374
  throw new Error(`View "${slug}" not found`);
374
375
  }