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
|
@@ -112,8 +112,7 @@ export async function pluginMarketplaceRoutes(fastify) {
|
|
|
112
112
|
// bare "Installation failed." while Install from file showed it.
|
|
113
113
|
const safe = err.expose === true || [
|
|
114
114
|
'Plugin already installed', 'Plugin signature invalid', 'Unknown public key',
|
|
115
|
-
'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade'
|
|
116
|
-
'This site accepts signed plugins only'
|
|
115
|
+
'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade'
|
|
117
116
|
].some(m => err.message.startsWith(m));
|
|
118
117
|
return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
|
|
119
118
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Plugins API
|
|
3
3
|
* GET /api/plugins - list all discovered plugins + state (admin only)
|
|
4
4
|
* GET /api/plugins/_count - the sidebar badge (`_` is outside the slug alphabet)
|
|
5
|
+
* GET /api/plugins/_policy - {signedOnly}: what the screens may offer (services/codePolicy.js)
|
|
5
6
|
* POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
|
|
6
7
|
* PUT /api/plugins/:name - enable/disable, update settings (admin only)
|
|
7
8
|
* GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
|
|
@@ -27,6 +28,7 @@ import {getToolStates, planSwitch, requiresOf, switchableUnits, usesOf} from '..
|
|
|
27
28
|
import {applyCascade} from './tools.js';
|
|
28
29
|
import {scaffoldPlugin} from '../../services/pluginScaffold.js';
|
|
29
30
|
import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
|
|
31
|
+
import {signedOnly, signedOnlyError} from '../../services/codePolicy.js';
|
|
30
32
|
import {getConfig} from '../../config.js';
|
|
31
33
|
import path from 'path';
|
|
32
34
|
import {existsSync, readFileSync} from 'node:fs';
|
|
@@ -217,6 +219,10 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
217
219
|
const canUpdate = { preHandler: [authenticate, requirePermission('plugins', 'update')] };
|
|
218
220
|
const canDevelop = { preHandler: [authenticate, requirePermission('plugins', 'develop')] };
|
|
219
221
|
|
|
222
|
+
// A signed-only refusal carries its code so a screen can tell it from a failure.
|
|
223
|
+
const mapErr = (reply, err) => reply.status(err.statusCode || 500)
|
|
224
|
+
.send({ error: err.message, ...(err.code === 'signed-only' && {code: err.code}) });
|
|
225
|
+
|
|
220
226
|
// List all plugins with their current state
|
|
221
227
|
fastify.get('/plugins', canRead, async (request) => {
|
|
222
228
|
const manifests = await discoverPlugins();
|
|
@@ -286,6 +292,12 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
286
292
|
return pluginsBadge(plugins, await latestVersions());
|
|
287
293
|
});
|
|
288
294
|
|
|
295
|
+
// What this site lets its admin do with plugin code. A signed-only site
|
|
296
|
+
// (services/codePolicy.js) refuses New plugin, unsigned uploads and code
|
|
297
|
+
// edits - the screens ask so they can leave those off rather than offer
|
|
298
|
+
// them and show the refusal. The refusal is the guard, not this.
|
|
299
|
+
fastify.get('/plugins/_policy', canRead, async () => ({signedOnly: signedOnly()}));
|
|
300
|
+
|
|
289
301
|
// Scaffold a new plugin from plugins/_template. The plugin is created
|
|
290
302
|
// pre-enabled so it goes live on the next server restart (its routes and
|
|
291
303
|
// admin views only register at boot - same restart rule as enable/disable).
|
|
@@ -296,7 +308,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
296
308
|
savePluginState(result.name, { enabled: true });
|
|
297
309
|
return { success: true, name: result.name, files: result.files, restartRequired: true };
|
|
298
310
|
} catch (err) {
|
|
299
|
-
return reply
|
|
311
|
+
return mapErr(reply, err);
|
|
300
312
|
}
|
|
301
313
|
});
|
|
302
314
|
|
|
@@ -371,13 +383,12 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
371
383
|
});
|
|
372
384
|
|
|
373
385
|
// Return merged admin config (sidebar, routes, views) for enabled plugins
|
|
374
|
-
fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async () => {
|
|
375
|
-
return getAdminPluginConfig();
|
|
386
|
+
fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async (request) => {
|
|
387
|
+
return getAdminPluginConfig(request.user);
|
|
376
388
|
});
|
|
377
389
|
|
|
378
390
|
// ── In-admin code editor (plugins.develop - code execution grade) ──────
|
|
379
391
|
|
|
380
|
-
const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
|
|
381
392
|
|
|
382
393
|
/*
|
|
383
394
|
* May this request read a LICENSED plugin's source?
|
|
@@ -388,7 +399,9 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
388
399
|
* without opening it on the sites they are distributed to. Level rather
|
|
389
400
|
* than role name, the same way requireAdmin and canManageUser decide.
|
|
390
401
|
*/
|
|
391
|
-
|
|
402
|
+
// A signed-only site is a hosted one, where level 0 is the customer - the
|
|
403
|
+
// assumption above does not hold there, so nobody reads licensed source.
|
|
404
|
+
const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0 && !signedOnly();
|
|
392
405
|
const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
|
|
393
406
|
|
|
394
407
|
// List a plugin's editable files
|
|
@@ -465,6 +478,14 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
465
478
|
return reply.code(400).send({error: err.message});
|
|
466
479
|
}
|
|
467
480
|
|
|
481
|
+
// A signed-only site refuses an unsigned file outright - before the
|
|
482
|
+
// "already installed, update it?" question, which would otherwise be
|
|
483
|
+
// asked about something that can never be installed.
|
|
484
|
+
if (!parts.signature && signedOnly()) {
|
|
485
|
+
const err = signedOnlyError();
|
|
486
|
+
return reply.code(err.statusCode).send({error: err.message, code: err.code});
|
|
487
|
+
}
|
|
488
|
+
|
|
468
489
|
// The slug comes from the manifest inside, never from the filename: a
|
|
469
490
|
// file can be renamed by anyone, a manifest is what the installer then
|
|
470
491
|
// validates against what was actually extracted.
|
|
@@ -496,6 +517,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
496
517
|
});
|
|
497
518
|
} catch (err) {
|
|
498
519
|
fastify.log.warn({err}, '[plugins] upload install failed');
|
|
520
|
+
if (err.code === 'signed-only') return mapErr(reply, err);
|
|
499
521
|
return reply.code(400).send({error: err.message});
|
|
500
522
|
}
|
|
501
523
|
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
18
18
|
*/
|
|
19
19
|
import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
|
|
20
|
+
import {isDcmStaff} from '../../services/staffAccess.js';
|
|
20
21
|
import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
21
22
|
import {filterItemsForUser, getMenu} from '../../services/menus.js';
|
|
22
23
|
import {isDashboardPro} from '../../services/widgets.js';
|
|
@@ -60,6 +61,8 @@ export async function sidebarRoutes(fastify, opts = {}) {
|
|
|
60
61
|
? {...entry, item: {...entry.item, text: boardLabel}}
|
|
61
62
|
: entry))
|
|
62
63
|
.filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
|
|
64
|
+
// A staff-only Tool's entry (services/tools.js `staff`): the Manager's staff sessions only.
|
|
65
|
+
.filter(entry => !entry?.item?.staffOnly || isDcmStaff(request.user))
|
|
63
66
|
.map(entry => Array.isArray(entry.item?.items)
|
|
64
67
|
? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
|
|
65
68
|
: entry);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Built-in Tool switches - Contacts, Notes, Todo, Analytics (services/tools.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/tools - each built-in Tool, on/off, locked, and the plugins that depend on it (plugins.read)
|
|
5
|
-
* GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off)
|
|
5
|
+
* GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off);
|
|
6
|
+
* a staff-only Tool (`staff: true`, services/tools.js) is true for a staff session only
|
|
6
7
|
* PUT /api/tools/:name - {enabled, cascade?} (plugins.update). Answers 423 when the site's manager has
|
|
7
8
|
* locked it, and 409 with the plan when the switch moves other Tools too - repeat
|
|
8
9
|
* with `cascade: true` to apply. Restarts the site like a plugin switch does.
|
|
@@ -12,7 +13,10 @@ import {
|
|
|
12
13
|
requirePermission as defaultRequirePermission
|
|
13
14
|
} from '../../middleware/auth.js';
|
|
14
15
|
import {discoverPlugins, getPluginStates, runLifecycleHook, savePluginState} from '../../services/plugins.js';
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
CORE_TOOLS, getToolStates, isCoreTool, isToolEnabled, isToolStaffOnly, planSwitch, saveToolState, switchableUnits, toolOpenAt
|
|
18
|
+
} from '../../services/tools.js';
|
|
19
|
+
import {isDcmStaff, isStaffRequest} from '../../services/staffAccess.js';
|
|
16
20
|
import {autoRestartEnabled, hasSupervisor, scheduleRestart} from './plugins.js';
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -39,15 +43,22 @@ export async function applyCascade(cascaded, fastify) {
|
|
|
39
43
|
* the Tool's prefix; the inner routes must NOT be given it again, or every
|
|
40
44
|
* route lands at /api/notes/api/notes/... (0.88.0 did exactly that on dcm).
|
|
41
45
|
*
|
|
46
|
+
* Off with `staff: true` (services/tools.js) lets a staff session through
|
|
47
|
+
* (services/staffAccess.js) and nobody else - not the site's own top role;
|
|
48
|
+
* the routes' own authenticate/permission checks still run.
|
|
49
|
+
*
|
|
42
50
|
* @param {string} name the Tool (services/tools.js CORE_TOOLS)
|
|
43
51
|
* @param {Function} routes its Fastify plugin
|
|
44
52
|
* @param {(name: string) => boolean} [isEnabled]
|
|
53
|
+
* @param {{isStaffOnly?: (name: string) => boolean, staffPass?: (request: object) => Promise<boolean>}} [opts]
|
|
45
54
|
*/
|
|
46
|
-
export function toolGate(name, routes, isEnabled = isToolEnabled) {
|
|
55
|
+
export function toolGate(name, routes, isEnabled = isToolEnabled, {isStaffOnly = isToolStaffOnly, staffPass = isStaffRequest} = {}) {
|
|
47
56
|
const displayName = CORE_TOOLS.find(t => t.name === name)?.displayName || name;
|
|
48
57
|
return async (scope) => {
|
|
49
58
|
scope.addHook('onRequest', async (request, reply) => {
|
|
50
|
-
if (
|
|
59
|
+
if (isEnabled(name)) return;
|
|
60
|
+
if (isStaffOnly(name) && await staffPass(request)) return;
|
|
61
|
+
return reply.code(404).send({error: `${displayName} is switched off on this site.`});
|
|
51
62
|
});
|
|
52
63
|
await scope.register(routes);
|
|
53
64
|
};
|
|
@@ -74,6 +85,8 @@ export async function toolsRoutes(fastify, opts = {}) {
|
|
|
74
85
|
...t,
|
|
75
86
|
enabled: u.enabled,
|
|
76
87
|
locked: u.locked,
|
|
88
|
+
// Off for the site, kept open to the Manager's staff sessions.
|
|
89
|
+
staff: u.staff === true,
|
|
77
90
|
// Enabled plugins that stop, or lose something, if this goes off.
|
|
78
91
|
requiredBy: plugins.filter(p => p.enabled && p.requires.includes(t.name)).map(p => ({name: p.name, displayName: p.displayName})),
|
|
79
92
|
usedBy: plugins.filter(p => p.enabled && t.name in p.uses).map(p => ({name: p.name, displayName: p.displayName, reason: p.uses[t.name]}))
|
|
@@ -81,9 +94,10 @@ export async function toolsRoutes(fastify, opts = {}) {
|
|
|
81
94
|
});
|
|
82
95
|
});
|
|
83
96
|
|
|
84
|
-
fastify.get('/tools/enabled', {preHandler: [authenticate]}, async () => {
|
|
97
|
+
fastify.get('/tools/enabled', {preHandler: [authenticate]}, async (request) => {
|
|
85
98
|
const states = getToolStates();
|
|
86
|
-
|
|
99
|
+
const staff = isDcmStaff(request.user);
|
|
100
|
+
return Object.fromEntries(CORE_TOOLS.map(t => [t.name, toolOpenAt(states[t.name], staff)]));
|
|
87
101
|
});
|
|
88
102
|
|
|
89
103
|
fastify.put('/tools/:name', canUpdate, async (request, reply) => {
|
package/server/server.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Domma dist files are served directly from node_modules/domma-js/public/dist.
|
|
5
5
|
* Updated: blobs + z-index fixes.
|
|
6
6
|
*/
|
|
7
|
-
import '
|
|
7
|
+
import './env.js';
|
|
8
8
|
import Fastify from 'fastify';
|
|
9
9
|
import jwt from '@fastify/jwt';
|
|
10
10
|
import staticPlugin from '@fastify/static';
|
|
@@ -578,7 +578,7 @@ await app.register(scaffoldRoutes, {prefix: '/api'});
|
|
|
578
578
|
// Each can be switched off (config/tools.json, services/tools.js): the gate is
|
|
579
579
|
// read per request, so an API stops answering the moment the switch is saved,
|
|
580
580
|
// before the restart that takes its sidebar entry and sources away.
|
|
581
|
-
const {isToolEnabled} = await import('./services/tools.js');
|
|
581
|
+
const {isToolEnabled, isToolStaffOnly} = await import('./services/tools.js');
|
|
582
582
|
const {toolGate, toolsRoutes} = await import('./routes/api/tools.js');
|
|
583
583
|
await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
|
|
584
584
|
await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
|
|
@@ -635,7 +635,13 @@ await app.register(relatedRoutes, {prefix: '/api/related'});
|
|
|
635
635
|
const {registerSidebarItem} = await import('./services/hooks.js');
|
|
636
636
|
// `countUrl` is the badge: the sidebar fetches it and shows {count, label, tone}
|
|
637
637
|
// (see fetchCounts in admin/js/lib/sidebar-renderer.js).
|
|
638
|
-
// A built-in Tool switched off gets no entry (its switch restarts the site)
|
|
638
|
+
// A built-in Tool switched off gets no entry (its switch restarts the site) - unless it is
|
|
639
|
+
// off with `staff: true` (the Manager's lock on hosted sites): then the entry is there for
|
|
640
|
+
// the Manager's staff sessions only (`staffOnly`, checked per viewer by /api/sidebar/registered-items).
|
|
641
|
+
const toolEntry = (name, item) => {
|
|
642
|
+
if (isToolEnabled(name)) registerSidebarItem({item: {source: 'Built in', ...item}});
|
|
643
|
+
else if (isToolStaffOnly(name)) registerSidebarItem({item: {source: 'Built in', ...item, staffOnly: true}});
|
|
644
|
+
};
|
|
639
645
|
const coreToolItems = [
|
|
640
646
|
{text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
|
|
641
647
|
{text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
|
|
@@ -644,12 +650,8 @@ const coreToolItems = [
|
|
|
644
650
|
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts', countUrl: '/api/contacts/count', inventory: true},
|
|
645
651
|
{text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
|
|
646
652
|
];
|
|
647
|
-
for (const item of coreToolItems)
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
if (isToolEnabled('wordpress-import')) {
|
|
651
|
-
registerSidebarItem({item: {source: 'Built in', text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true}});
|
|
652
|
-
}
|
|
653
|
+
for (const item of coreToolItems) toolEntry(item.permission, item);
|
|
654
|
+
toolEntry('wordpress-import', {text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true});
|
|
653
655
|
// Todo's to-dos on the calendar (registered when routes/api/todo.js loads).
|
|
654
656
|
if (!isToolEnabled('todo')) {
|
|
655
657
|
const {unregisterCalendarSource} = await import('./services/hooks.js');
|
|
@@ -747,6 +749,10 @@ await app.register(docsPublicRoutes);
|
|
|
747
749
|
// An instant call's page, /_call/<id> (a reserved prefix, like /_preview) - before the catch-all.
|
|
748
750
|
const { callPublicRoutes } = await import('./routes/call-public.js');
|
|
749
751
|
await app.register(callPublicRoutes);
|
|
752
|
+
// A staff session from the site's Manager (/_staff/open?ticket=…, services/staffAccess.js) - the one way
|
|
753
|
+
// into a Tool switched off with `staff: true`. A ticket only works on a site its Manager started.
|
|
754
|
+
const { staffAccessRoutes } = await import('./services/staffAccess.js');
|
|
755
|
+
await app.register(staffAccessRoutes);
|
|
750
756
|
|
|
751
757
|
// The outreach unsubscribe link (/campaign-unsubscribe) - core's, so it outlives the Tool that
|
|
752
758
|
// sent the email. After the plugins: an older Campaigns Pro that registers it itself wins.
|
|
@@ -23,6 +23,7 @@ import path from 'path';
|
|
|
23
23
|
import { config } from '../../config.js';
|
|
24
24
|
import { applyFilter } from '../filterEngine.js';
|
|
25
25
|
import { withFileLock, writeFileAtomic } from './fileLock.js';
|
|
26
|
+
import {fileIn} from '../safePath.js';
|
|
26
27
|
|
|
27
28
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
28
29
|
|
|
@@ -31,7 +32,7 @@ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
|
|
33
34
|
function dataPath(slug) {
|
|
34
|
-
return path.join(COLLECTIONS_DIR, slug, 'data.json');
|
|
35
|
+
return path.join(fileIn(COLLECTIONS_DIR, slug), 'data.json');
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
async function read(slug) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-only sites: who may put server-side code on this site.
|
|
3
|
+
*
|
|
4
|
+
* `config/server.json` `requireSignedPlugins: true` means the only plugin code
|
|
5
|
+
* this site runs is code the Marketplace signed. A hosted site (one a Domma CMS
|
|
6
|
+
* Manager runs) has it on, because its owner holds the top role there and a
|
|
7
|
+
* level-0 user who can run their own code can read the site's secrets - and
|
|
8
|
+
* with JWT_SECRET forge a Manager staff session (services/staffAccess.js).
|
|
9
|
+
* Self-hosted sites leave it off: there the owner IS the host.
|
|
10
|
+
*
|
|
11
|
+
* With it on, every way the admin had of getting its own JavaScript onto the
|
|
12
|
+
* server is refused here, at the service, so a second route or a plugin calling
|
|
13
|
+
* the same service cannot step round it:
|
|
14
|
+
*
|
|
15
|
+
* - installing an unsigned .dcmsplugin (pluginInstaller.installPlugin)
|
|
16
|
+
* - New plugin (pluginScaffold.scaffoldPlugin)
|
|
17
|
+
* - writing or deleting a plugin's files in the code editor (pluginFiles)
|
|
18
|
+
* - reading a LICENSED plugin's source as level 0 - on a hosted site level 0
|
|
19
|
+
* is the customer, not the people who publish the plugin (routes/api/plugins.js)
|
|
20
|
+
*
|
|
21
|
+
* Three things say "on", and any one is enough - none can be undone from the
|
|
22
|
+
* site's own admin:
|
|
23
|
+
*
|
|
24
|
+
* 1. `DOMMA_SIGNED_ONLY=1` in the environment. The Manager sets it when it
|
|
25
|
+
* forks a site it hosts (after the site's .env, so .env cannot unset it).
|
|
26
|
+
* This is the one a file-write bug cannot touch.
|
|
27
|
+
* 2. server.json as it was at boot (config.server). Deleting or rewriting
|
|
28
|
+
* the file while the site runs changes nothing until a restart.
|
|
29
|
+
* 3. server.json now, read fresh - so the Manager can switch a running site
|
|
30
|
+
* on without restarting it.
|
|
31
|
+
*
|
|
32
|
+
* Nothing in the admin or the API writes server.json - it is the host's file.
|
|
33
|
+
* Keep it that way: a route that ever writes it must leave
|
|
34
|
+
* `requireSignedPlugins` exactly as it found it.
|
|
35
|
+
*
|
|
36
|
+
* @module services/codePolicy
|
|
37
|
+
*/
|
|
38
|
+
import {config, getConfig} from '../config.js';
|
|
39
|
+
|
|
40
|
+
/** What the admin is told, whatever it tried. Plain words: it is the whole answer. */
|
|
41
|
+
export const SIGNED_ONLY_MESSAGE = 'This site only runs plugins signed by the Marketplace, so plugin code cannot be uploaded, created or edited here. Install plugins from Browse.';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Does this site refuse any plugin code the Marketplace has not signed?
|
|
45
|
+
*
|
|
46
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function signedOnly(env = process.env) {
|
|
50
|
+
if (env.DOMMA_SIGNED_ONLY === '1') return true;
|
|
51
|
+
if (config.server?.requireSignedPlugins === true) return true;
|
|
52
|
+
try {
|
|
53
|
+
return getConfig('server')?.requireSignedPlugins === true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The refusal, as an Error the routes map straight to a reply
|
|
61
|
+
* (`statusCode` 403, `code` 'signed-only', `expose` for the marketplace route).
|
|
62
|
+
*
|
|
63
|
+
* @returns {Error}
|
|
64
|
+
*/
|
|
65
|
+
export function signedOnlyError() {
|
|
66
|
+
return Object.assign(new Error(SIGNED_ONLY_MESSAGE), {statusCode: 403, code: 'signed-only', expose: true});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Throw the refusal when the site is signed-only. */
|
|
70
|
+
export function assertMayChangeCode() {
|
|
71
|
+
if (signedOnly()) throw signedOnlyError();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* MongoDB client options that reach outside the database: start a program
|
|
76
|
+
* (`autoEncryption.extraOptions.mongocryptdSpawnPath` / `...SpawnArgs`), load a
|
|
77
|
+
* native library (`cryptSharedLibPath`) or read a file off this server
|
|
78
|
+
* (`tlsCAFile`, `tlsCertificateKeyFile`, the old `ssl*` names). The admin
|
|
79
|
+
* writes connections.json (PUT /api/collections/connections) and the site -
|
|
80
|
+
* and the Manager's Data Manager - hand its `options` to `new MongoClient`.
|
|
81
|
+
* Today the spawn needs `mongodb-client-encryption`, which is not installed;
|
|
82
|
+
* this keeps it that way on a signed-only site whatever gets installed later.
|
|
83
|
+
*/
|
|
84
|
+
const RISKY_MONGO_OPTION = /^(autoEncryption|extraOptions|.*File|.*Path|.*SpawnArgs|ssl(CA|Cert|Key|CRL|PEMKeyFile|PEMKeyPassword)|cryptShared.*|mongocryptd.*)$/i;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The risky option names in one stored connection (its `options`, at any depth,
|
|
88
|
+
* and its URI's query string). Empty when it is fine.
|
|
89
|
+
*
|
|
90
|
+
* @param {{uri?: string, options?: object}} conn
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
export function riskyConnectionOptions(conn) {
|
|
94
|
+
const found = [];
|
|
95
|
+
const walk = (obj, at) => {
|
|
96
|
+
if (!obj || typeof obj !== 'object') return;
|
|
97
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
98
|
+
const here = at ? `${at}.${k}` : k;
|
|
99
|
+
if (RISKY_MONGO_OPTION.test(k)) found.push(here);
|
|
100
|
+
else walk(v, here);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
walk(conn?.options, 'options');
|
|
104
|
+
const query = String(conn?.uri || '').split('?')[1] || '';
|
|
105
|
+
for (const pair of query.split('&')) {
|
|
106
|
+
let key = pair.split('=')[0];
|
|
107
|
+
try { key = decodeURIComponent(key); } catch { /* keep it raw */ }
|
|
108
|
+
if (key && RISKY_MONGO_OPTION.test(key)) found.push(`uri ${key}`);
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
@@ -14,6 +14,7 @@ import {adapterForStorage, describeAdapter, getAdapter, invalidate, isFixedToFil
|
|
|
14
14
|
import * as cache from './cache/index.js';
|
|
15
15
|
import {typedEntryData} from './entryValues.js';
|
|
16
16
|
import {toCsv} from '../../public/js/collection-export.mjs';
|
|
17
|
+
import {fileIn} from './safePath.js';
|
|
17
18
|
|
|
18
19
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
19
20
|
const TOOL_TRASH_SLUGS = new Set(['notes-notes', 'todo-items', 'contacts-contacts']);
|
|
@@ -27,7 +28,7 @@ async function ensureDir() {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function collectionDir(slug) {
|
|
30
|
-
return
|
|
31
|
+
return fileIn(COLLECTIONS_DIR, slug);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function schemaPath(slug) {
|
|
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
9
|
import {getPluginComponents} from './hooks.js';
|
|
10
|
+
import {fileIn} from './safePath.js';
|
|
10
11
|
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const COMPONENTS_DIR = path.resolve(__dirname, '../../content/components');
|
|
@@ -28,7 +29,7 @@ function assertValidName(name) {
|
|
|
28
29
|
* @param {string} name - Component name (without extension).
|
|
29
30
|
* @returns {string}
|
|
30
31
|
*/
|
|
31
|
-
function componentFilePath(name) { return
|
|
32
|
+
function componentFilePath(name) { return fileIn(COMPONENTS_DIR, name, '.dmc'); }
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Absolute filesystem path to a component's companion .meta.json file.
|
|
@@ -36,7 +37,7 @@ function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc
|
|
|
36
37
|
* @param {string} name - Component name (without extension).
|
|
37
38
|
* @returns {string}
|
|
38
39
|
*/
|
|
39
|
-
function componentMetaPath(name) { return
|
|
40
|
+
function componentMetaPath(name) { return fileIn(COMPONENTS_DIR, name, '.meta.json'); }
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Split a .dmc source string into its four SFC blocks.
|
|
@@ -35,6 +35,14 @@ export async function initialise(connectionsConfig) {
|
|
|
35
35
|
continue;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Written before the site went signed-only, or by hand: not used there.
|
|
39
|
+
const {riskyConnectionOptions, signedOnly} = await import('./codePolicy.js');
|
|
40
|
+
const risky = signedOnly() ? riskyConnectionOptions(cfg) : [];
|
|
41
|
+
if (risky.length) {
|
|
42
|
+
console.error(`[connectionManager] Skipping connection "${name}": ${risky.join(', ')} not allowed on a signed-only site`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
try {
|
|
39
47
|
const client = new MongoClient(cfg.uri, cfg.options || {});
|
|
40
48
|
await client.connect();
|
|
@@ -501,7 +501,7 @@ function urlPathToFilePath(urlPath) {
|
|
|
501
501
|
const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
502
502
|
const result = path.join(PAGES_DIR, normalised + '.md');
|
|
503
503
|
const resolved = path.resolve(result);
|
|
504
|
-
if (!resolved.startsWith(path.resolve(PAGES_DIR))) {
|
|
504
|
+
if (!resolved.startsWith(path.resolve(PAGES_DIR) + path.sep)) {
|
|
505
505
|
throw new Error('Invalid path: outside content directory');
|
|
506
506
|
}
|
|
507
507
|
return result;
|
|
@@ -20,6 +20,7 @@ import path from 'path';
|
|
|
20
20
|
import {fileURLToPath} from 'url';
|
|
21
21
|
import * as cache from './cache/index.js';
|
|
22
22
|
import {cssClassProblems} from '../../public/js/css-classes.mjs';
|
|
23
|
+
import {fileIn} from './safePath.js';
|
|
23
24
|
|
|
24
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
|
|
@@ -66,7 +67,7 @@ export async function ensureContextMenusDir() {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
function ctxPath(slug) {
|
|
69
|
-
return
|
|
70
|
+
return fileIn(CTX_DIR, slug, '.json');
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function readCtxFile(slug) {
|
package/server/services/forms.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'path';
|
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
9
|
import * as cache from './cache/index.js';
|
|
10
10
|
import {buttonSettingsProblems, formClassProblems} from '../../public/js/form-submit.mjs';
|
|
11
|
+
import {fileIn} from './safePath.js';
|
|
11
12
|
|
|
12
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -30,7 +31,7 @@ export async function ensureFormsDir() {
|
|
|
30
31
|
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
31
32
|
*/
|
|
32
33
|
export async function readForm(slug) {
|
|
33
|
-
const file =
|
|
34
|
+
const file = fileIn(FORMS_DIR, slug, '.json');
|
|
34
35
|
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -43,7 +44,7 @@ export async function readForm(slug) {
|
|
|
43
44
|
*/
|
|
44
45
|
export async function writeForm(slug, data) {
|
|
45
46
|
await ensureFormsDir();
|
|
46
|
-
const file =
|
|
47
|
+
const file = fileIn(FORMS_DIR, slug, '.json');
|
|
47
48
|
await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
|
|
48
49
|
await cache.invalidateTags([`form:${slug}`]);
|
|
49
50
|
}
|
|
@@ -83,7 +84,7 @@ export async function listForms() {
|
|
|
83
84
|
* @throws {Error} If the form file does not exist.
|
|
84
85
|
*/
|
|
85
86
|
export async function deleteForm(slug) {
|
|
86
|
-
await fs.unlink(
|
|
87
|
+
await fs.unlink(fileIn(FORMS_DIR, slug, '.json'));
|
|
87
88
|
await cache.invalidateTags([`form:${slug}`]);
|
|
88
89
|
}
|
|
89
90
|
|
package/server/services/menus.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
LEGACY_STYLE_HOVER, LEGACY_STYLE_ACTIVE, legacyStyleHighlight,
|
|
22
22
|
PRESET_COLOUR_VARS, PRESET_COLOUR_FALLBACKS, colourToCss, cssClassProblems
|
|
23
23
|
} from '../../public/js/menu-decor.mjs';
|
|
24
|
+
import {fileIn} from './safePath.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Bust the response-cache tags that menu changes affect. Public pages are
|
|
@@ -472,7 +473,7 @@ export async function ensureMenusDir() {
|
|
|
472
473
|
}
|
|
473
474
|
|
|
474
475
|
function menuPath(slug) {
|
|
475
|
-
return
|
|
476
|
+
return fileIn(MENUS_DIR, slug, '.json');
|
|
476
477
|
}
|
|
477
478
|
|
|
478
479
|
async function readMenuFile(slug) {
|
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
* exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
|
|
14
14
|
* the manager: the people who publish the paid plugins are the people who need
|
|
15
15
|
* to read and edit them. Customer sites are all level 1, so nothing changes
|
|
16
|
-
* there - see the `allowLicensed` argument, set from the request's user.
|
|
16
|
+
* there - see the `allowLicensed` argument, set from the request's user. (Hosted
|
|
17
|
+
* owners are in fact level 0 on their own site: on a signed-only site the route
|
|
18
|
+
* never allows licensed source - services/codePolicy.js.)
|
|
17
19
|
*
|
|
18
20
|
* Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
|
|
19
21
|
* 413 too large) for direct mapping in the route layer.
|
|
20
22
|
*/
|
|
21
23
|
import fs from 'fs/promises';
|
|
22
24
|
import path from 'path';
|
|
25
|
+
import {assertMayChangeCode} from './codePolicy.js';
|
|
23
26
|
|
|
24
27
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
25
28
|
|
|
@@ -200,6 +203,8 @@ export async function readPluginFile(name, relPath, {allowLicensed = false} = {}
|
|
|
200
203
|
* @returns {Promise<{path: string, restartRequired: boolean}>}
|
|
201
204
|
*/
|
|
202
205
|
export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
|
|
206
|
+
// A signed-only site runs the Marketplace's code as signed: nothing here may change it.
|
|
207
|
+
assertMayChangeCode();
|
|
203
208
|
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
204
209
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
205
210
|
|
|
@@ -240,6 +245,8 @@ export async function writePluginFile(name, relPath, content, {allowLicensed = f
|
|
|
240
245
|
* @returns {Promise<{path: string}>}
|
|
241
246
|
*/
|
|
242
247
|
export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
|
|
248
|
+
// A signed-only site runs the Marketplace's code as signed: nothing here may change it.
|
|
249
|
+
assertMayChangeCode();
|
|
243
250
|
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
244
251
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
245
252
|
const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
|
|
@@ -16,9 +16,9 @@ import {readFileSync} from 'fs';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import zlib from 'zlib';
|
|
18
18
|
import {fileURLToPath} from 'url';
|
|
19
|
-
import {getConfig} from '../config.js';
|
|
20
19
|
import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
|
|
21
20
|
import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
|
|
21
|
+
import {signedOnly, signedOnlyError} from './codePolicy.js';
|
|
22
22
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
// Paths
|
|
@@ -74,18 +74,14 @@ MCowBQYDK2VwAyEAqAlx1lfHeCaNiwJmoXZMDY56yafU2GlpNG3VInw9Q14=
|
|
|
74
74
|
* Does this site refuse anything the marketplace has not signed?
|
|
75
75
|
*
|
|
76
76
|
* Default false, because a CMS whose own scaffolder produces unsigned plugins
|
|
77
|
-
* cannot refuse unsigned plugins without refusing its user's own work. A
|
|
78
|
-
* site
|
|
79
|
-
*
|
|
77
|
+
* cannot refuse unsigned plugins without refusing its user's own work. A hosted
|
|
78
|
+
* site has it on (services/codePolicy.js - the one place that decides, and the
|
|
79
|
+
* other doors it closes).
|
|
80
80
|
*
|
|
81
81
|
* @returns {boolean}
|
|
82
82
|
*/
|
|
83
83
|
function requireSignedPlugins() {
|
|
84
|
-
|
|
85
|
-
return getConfig('server')?.requireSignedPlugins === true;
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
84
|
+
return signedOnly();
|
|
89
85
|
}
|
|
90
86
|
|
|
91
87
|
/**
|
|
@@ -348,12 +344,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
|
|
|
348
344
|
// Step 1 - Verify signature, or establish that there is deliberately none.
|
|
349
345
|
const unsigned = !signature;
|
|
350
346
|
if (unsigned) {
|
|
347
|
+
// Signed-only first: asking the admin to confirm something the site will
|
|
348
|
+
// refuse anyway would be a question with no right answer.
|
|
349
|
+
if (requireSignedPlugins()) throw signedOnlyError();
|
|
351
350
|
if (!allowUnsigned) {
|
|
352
351
|
throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
|
|
353
352
|
}
|
|
354
|
-
if (requireSignedPlugins()) {
|
|
355
|
-
throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
|
|
356
|
-
}
|
|
357
353
|
}
|
|
358
354
|
|
|
359
355
|
const pubKeyPem = unsigned ? null : trustedKey(publicKeyId, 'bundle');
|
|
@@ -396,7 +392,14 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
|
|
|
396
392
|
throw err;
|
|
397
393
|
}
|
|
398
394
|
|
|
399
|
-
// Step 4 - Validate manifest
|
|
395
|
+
// Step 4 - Validate manifest. discoverPlugins() names a plugin after its
|
|
396
|
+
// folder, so it cannot tell a bundle installed under someone else's slug -
|
|
397
|
+
// the manifest inside must name this slug itself (as an upgrade checks).
|
|
398
|
+
const own = await validatePluginDir(destDir, slug);
|
|
399
|
+
if (!own.ok) {
|
|
400
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
401
|
+
throw new Error(`Plugin manifest validation failed after extraction: ${slug} (${own.reason})`);
|
|
402
|
+
}
|
|
400
403
|
const manifests = await discoverPlugins();
|
|
401
404
|
const manifest = manifests.find(m => m.name === slug);
|
|
402
405
|
if (!manifest) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import fs from 'fs/promises';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
|
|
15
|
+
import {assertMayChangeCode} from './codePolicy.js';
|
|
15
16
|
|
|
16
17
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
17
18
|
const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
|
|
@@ -71,6 +72,8 @@ export function validateScaffoldInput(input = {}) {
|
|
|
71
72
|
* @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
|
|
72
73
|
*/
|
|
73
74
|
export async function scaffoldPlugin(input) {
|
|
75
|
+
// A new plugin is new server code: a signed-only site makes none (services/codePolicy.js).
|
|
76
|
+
assertMayChangeCode();
|
|
74
77
|
const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
|
|
75
78
|
|
|
76
79
|
try {
|