domma-cms 1.8.13 → 1.8.15
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 +11 -0
- package/CLAUDE.md +29 -1
- 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 +2 -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/forms.js +9 -4
- package/server/routes/api/plugin-marketplace.js +1 -2
- package/server/routes/api/plugins.js +25 -3
- package/server/server.js +1 -1
- package/server/services/actions.js +5 -2
- package/server/services/adapters/FileAdapter.js +2 -1
- package/server/services/adapters/MongoAdapter.js +22 -6
- 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/filterEngine.js +3 -1
- package/server/services/forms.js +4 -3
- package/server/services/menus.js +2 -1
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/outbound.js +394 -0
- package/server/services/pluginFiles.js +8 -1
- package/server/services/pluginInstaller.js +16 -13
- package/server/services/pluginScaffold.js +3 -0
- package/server/services/safePath.js +35 -0
- package/server/services/staffAccess.js +2 -1
- package/server/services/versions.js +1 -1
- package/server/services/views.js +17 -4
- package/server/services/wpImport/fetch.js +12 -181
|
@@ -45,6 +45,7 @@ import {hooks} from '../../services/hooks.js';
|
|
|
45
45
|
import {saveMedia} from '../../services/content.js';
|
|
46
46
|
import {v4 as uuidv4} from 'uuid';
|
|
47
47
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
48
|
+
import {safeFetch} from '../../services/outbound.js';
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Detect form↔collection mismatches at save time so admins know BEFORE
|
|
@@ -961,11 +962,15 @@ export async function formsRoutes(fastify) {
|
|
|
961
962
|
const webhookAction = form.actions?.webhook;
|
|
962
963
|
if (record && webhookAction?.enabled && webhookAction.url) {
|
|
963
964
|
try {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
965
|
+
// services/outbound.js: public addresses only, pinned, no redirect inward.
|
|
966
|
+
const method = String(webhookAction.method || 'POST').toUpperCase();
|
|
967
|
+
const res = await safeFetch(webhookAction.url, {
|
|
968
|
+
method,
|
|
969
|
+
headers: { 'Content-Type': 'application/json' },
|
|
970
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : JSON.stringify({ form: slug, data }),
|
|
971
|
+
truncate: true
|
|
968
972
|
});
|
|
973
|
+
if (!res.ok) throw new Error(`Webhook returned HTTP ${res.status}`);
|
|
969
974
|
} catch (err) {
|
|
970
975
|
fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
|
|
971
976
|
formActionFailed(slug, form.title, 'webhook', err.message);
|
|
@@ -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
|
|
|
@@ -377,7 +389,6 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
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
|
|
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';
|
|
@@ -203,10 +203,13 @@ async function executeStep(step, collection, entry, context) {
|
|
|
203
203
|
const body = cfg.body ? interpolateValue(cfg.body, context) : undefined;
|
|
204
204
|
const headers = { 'Content-Type': 'application/json', ...(cfg.headers || {}) };
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
// services/outbound.js: public addresses only, pinned, no redirect inward.
|
|
207
|
+
const { safeFetch } = await import('./outbound.js');
|
|
208
|
+
const res = await safeFetch(url, {
|
|
207
209
|
method,
|
|
208
210
|
headers,
|
|
209
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
211
|
+
body: body !== undefined && method !== 'GET' && method !== 'HEAD' ? JSON.stringify(body) : undefined,
|
|
212
|
+
truncate: true
|
|
210
213
|
});
|
|
211
214
|
|
|
212
215
|
if (!res.ok) throw new Error(`Webhook returned HTTP ${res.status}`);
|
|
@@ -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) {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* count(slug) → number
|
|
20
20
|
* archive(slug) → string|null (set the entries aside after a storage move)
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { toSafeMongoQuery } from '../mongoQueryGuard.js';
|
|
23
23
|
|
|
24
24
|
/** Prefix applied to all CMS collection names in MongoDB to avoid collisions. */
|
|
25
25
|
const PREFIX = 'cms_';
|
|
@@ -36,6 +36,19 @@ function strip(doc) {
|
|
|
36
36
|
return rest;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* An entry id as it may be put in a query: text only. An object from a JSON
|
|
41
|
+
* body (`{"$ne": ""}`) would otherwise be read as an operator and pick
|
|
42
|
+
* whichever entry matched first.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} entryId
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function idOf(entryId) {
|
|
48
|
+
if (typeof entryId !== 'string' && typeof entryId !== 'number') throw Object.assign(new Error('Entry not found'), {statusCode: 404});
|
|
49
|
+
return String(entryId);
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
export class MongoAdapter {
|
|
40
53
|
|
|
41
54
|
/**
|
|
@@ -67,7 +80,7 @@ export class MongoAdapter {
|
|
|
67
80
|
* and structured filtering.
|
|
68
81
|
*
|
|
69
82
|
* `filter` is translated to native MongoDB query operators via
|
|
70
|
-
* `
|
|
83
|
+
* `mongoQueryGuard.toSafeMongoQuery()` and pushed down to the database - page
|
|
71
84
|
* authors writing `[collection where_salary_gte="50000"]` get index-aware
|
|
72
85
|
* queries automatically. When `search` is present, all documents are
|
|
73
86
|
* fetched and filtered in memory (same behaviour as FileAdapter, kept
|
|
@@ -88,7 +101,9 @@ export class MongoAdapter {
|
|
|
88
101
|
|
|
89
102
|
const sortField = sort === 'createdAt' ? 'meta.createdAt' : `data.${sort}`;
|
|
90
103
|
const sortDir = order === 'desc' ? -1 : 1;
|
|
91
|
-
|
|
104
|
+
// The one translation of a user-shaped filter for MongoDB - refuses
|
|
105
|
+
// anything that would run code on the server (mongoQueryGuard.js).
|
|
106
|
+
const query = toSafeMongoQuery(filter);
|
|
92
107
|
|
|
93
108
|
if (search) {
|
|
94
109
|
// Fetch matching (by filter) docs and substring-match in memory.
|
|
@@ -140,8 +155,9 @@ export class MongoAdapter {
|
|
|
140
155
|
* @returns {Promise<object|null>}
|
|
141
156
|
*/
|
|
142
157
|
async get(slug, entryId) {
|
|
158
|
+
if (typeof entryId !== 'string' && typeof entryId !== 'number') return null;
|
|
143
159
|
const col = await this._col(slug);
|
|
144
|
-
const doc = await col.findOne({ id: entryId }, { projection: { _id: 0 } });
|
|
160
|
+
const doc = await col.findOne({ id: idOf(entryId) }, { projection: { _id: 0 } });
|
|
145
161
|
return doc || null;
|
|
146
162
|
}
|
|
147
163
|
|
|
@@ -169,7 +185,7 @@ export class MongoAdapter {
|
|
|
169
185
|
*/
|
|
170
186
|
async update(slug, entryId, updated) {
|
|
171
187
|
const col = await this._col(slug);
|
|
172
|
-
const result = await col.replaceOne({ id: entryId }, { ...updated });
|
|
188
|
+
const result = await col.replaceOne({ id: idOf(entryId) }, { ...updated });
|
|
173
189
|
if (result.matchedCount === 0) throw new Error('Entry not found');
|
|
174
190
|
return updated;
|
|
175
191
|
}
|
|
@@ -184,7 +200,7 @@ export class MongoAdapter {
|
|
|
184
200
|
*/
|
|
185
201
|
async remove(slug, entryId) {
|
|
186
202
|
const col = await this._col(slug);
|
|
187
|
-
const result = await col.deleteOne({ id: entryId });
|
|
203
|
+
const result = await col.deleteOne({ id: idOf(entryId) });
|
|
188
204
|
if (result.deletedCount === 0) throw new Error('Entry not found');
|
|
189
205
|
}
|
|
190
206
|
|
|
@@ -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) {
|
|
@@ -262,7 +262,9 @@ export function toMongoQuery(filter) {
|
|
|
262
262
|
|
|
263
263
|
// Collapse single-operator sub-docs that only contain $eq back to a flat value.
|
|
264
264
|
for (const [path, sub] of Object.entries(query)) {
|
|
265
|
-
|
|
265
|
+
// Only a plain value: an object here would be read as operators, not matched as a value.
|
|
266
|
+
if (sub && typeof sub === 'object' && Object.keys(sub).length === 1 && '$eq' in sub
|
|
267
|
+
&& (sub.$eq === null || typeof sub.$eq !== 'object')) {
|
|
266
268
|
query[path] = sub.$eq;
|
|
267
269
|
}
|
|
268
270
|
}
|
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) {
|