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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +29 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/views/plugin-code.js +3 -3
  5. package/admin/js/views/plugins.js +8 -8
  6. package/docs/DommaFeatures.md +2 -1
  7. package/docs/configuration.md +2 -1
  8. package/package.json +1 -1
  9. package/server/config.js +3 -2
  10. package/server/env.js +12 -0
  11. package/server/routes/api/collections.js +9 -0
  12. package/server/routes/api/forms.js +9 -4
  13. package/server/routes/api/plugin-marketplace.js +1 -2
  14. package/server/routes/api/plugins.js +25 -3
  15. package/server/server.js +1 -1
  16. package/server/services/actions.js +5 -2
  17. package/server/services/adapters/FileAdapter.js +2 -1
  18. package/server/services/adapters/MongoAdapter.js +22 -6
  19. package/server/services/codePolicy.js +111 -0
  20. package/server/services/collections.js +2 -1
  21. package/server/services/components.js +3 -2
  22. package/server/services/connectionManager.js +8 -0
  23. package/server/services/content.js +1 -1
  24. package/server/services/contextMenus.js +2 -1
  25. package/server/services/filterEngine.js +3 -1
  26. package/server/services/forms.js +4 -3
  27. package/server/services/menus.js +2 -1
  28. package/server/services/mongoQueryGuard.js +193 -0
  29. package/server/services/outbound.js +394 -0
  30. package/server/services/pluginFiles.js +8 -1
  31. package/server/services/pluginInstaller.js +16 -13
  32. package/server/services/pluginScaffold.js +3 -0
  33. package/server/services/safePath.js +35 -0
  34. package/server/services/staffAccess.js +2 -1
  35. package/server/services/versions.js +1 -1
  36. package/server/services/views.js +17 -4
  37. 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
- await fetch(webhookAction.url, {
965
- method: webhookAction.method || 'POST',
966
- headers: { 'Content-Type': 'application/json' },
967
- body: JSON.stringify({ form: slug, data })
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.status(err.statusCode || 500).send({ error: err.message });
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
- const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0;
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 'dotenv/config';
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
- const res = await fetch(url, {
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 { toMongoQuery } from '../filterEngine.js';
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
- * `filterEngine.toMongoQuery()` and pushed down to the database - page
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
- const query = filter ? toMongoQuery(filter) : {};
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 path.join(COLLECTIONS_DIR, slug);
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 path.join(COMPONENTS_DIR, `${name}.dmc`); }
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 path.join(COMPONENTS_DIR, `${name}.meta.json`); }
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 path.join(CTX_DIR, `${slug}.json`);
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
- if (sub && typeof sub === 'object' && Object.keys(sub).length === 1 && '$eq' in sub) {
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
  }
@@ -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 = path.join(FORMS_DIR, `${slug}.json`);
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 = path.join(FORMS_DIR, `${slug}.json`);
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(path.join(FORMS_DIR, `${slug}.json`));
87
+ await fs.unlink(fileIn(FORMS_DIR, slug, '.json'));
87
88
  await cache.invalidateTags([`form:${slug}`]);
88
89
  }
89
90
 
@@ -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 path.join(MENUS_DIR, `${slug}.json`);
476
+ return fileIn(MENUS_DIR, slug, '.json');
476
477
  }
477
478
 
478
479
  async function readMenuFile(slug) {