domma-cms 0.32.4 → 0.33.1

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.
@@ -1,42 +1,42 @@
1
- /**
2
- * Navigation API
3
- * GET /api/navigation - get navigation config
4
- * PUT /api/navigation - save navigation config
5
- */
6
- import {getConfig, saveConfig} from '../../config.js';
7
- import {authenticate, requirePermission} from '../../middleware/auth.js';
8
- import * as cache from '../../services/cache/index.js';
9
-
10
- export async function navigationRoutes(fastify) {
11
- const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
12
- const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
13
-
14
- fastify.get('/navigation', canRead, async () => {
15
- return getConfig('navigation');
16
- });
17
-
18
- fastify.put('/navigation', canUpdate, async (request, reply) => {
19
- const data = request.body;
20
- if (!data || typeof data !== 'object') {
21
- return reply.status(400).send({ error: 'Invalid navigation data' });
22
- }
23
- if (!Array.isArray(data.items) && !Array.isArray(data)) {
24
- return reply.status(400).send({ error: 'Navigation must be an array of items' });
25
- }
26
- // Normalise child key: Domma navbar expects `items`, not `children`
27
- if (Array.isArray(data.items)) {
28
- data.items = data.items.map(item => {
29
- const children = item.items || item.children;
30
- if (children?.length) {
31
- const { children: _c, ...rest } = item;
32
- return { ...rest, items: children };
33
- }
34
- const { children: _c, ...rest } = item;
35
- return rest;
36
- });
37
- }
38
- saveConfig('navigation', data);
39
- await cache.invalidateTags(['nav']);
40
- return { success: true };
41
- });
42
- }
1
+ /**
2
+ * Navigation API
3
+ * GET /api/navigation - get navigation config
4
+ * PUT /api/navigation - save navigation config
5
+ */
6
+ import {getConfig, saveConfig} from '../../config.js';
7
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
8
+ import * as cache from '../../services/cache/index.js';
9
+
10
+ export async function navigationRoutes(fastify) {
11
+ const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
12
+ const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
13
+
14
+ fastify.get('/navigation', canRead, async () => {
15
+ return getConfig('navigation');
16
+ });
17
+
18
+ fastify.put('/navigation', canUpdate, async (request, reply) => {
19
+ const data = request.body;
20
+ if (!data || typeof data !== 'object') {
21
+ return reply.status(400).send({ error: 'Invalid navigation data' });
22
+ }
23
+ if (!Array.isArray(data.items) && !Array.isArray(data)) {
24
+ return reply.status(400).send({ error: 'Navigation must be an array of items' });
25
+ }
26
+ // Normalise child key: Domma navbar expects `items`, not `children`
27
+ if (Array.isArray(data.items)) {
28
+ data.items = data.items.map(item => {
29
+ const children = item.items || item.children;
30
+ if (children?.length) {
31
+ const { children: _c, ...rest } = item;
32
+ return { ...rest, items: children };
33
+ }
34
+ const { children: _c, ...rest } = item;
35
+ return rest;
36
+ });
37
+ }
38
+ saveConfig('navigation', data);
39
+ await cache.invalidateTags(['nav']);
40
+ return { success: true };
41
+ });
42
+ }
@@ -1,141 +1,141 @@
1
- /**
2
- * Settings API
3
- * GET /api/settings - get site settings
4
- * PUT /api/settings - save site settings
5
- * POST /api/settings/test-email - send a test email using stored SMTP config
6
- */
7
- import {getConfig, saveConfig} from '../../config.js';
8
- import {authenticate, requirePermission} from '../../middleware/auth.js';
9
- import nodemailer from 'nodemailer';
10
- import fs from 'fs/promises';
11
- import path from 'path';
12
- import {fileURLToPath} from 'url';
13
- import * as cache from '../../services/cache/index.js';
14
-
15
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
- const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
17
- const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
18
- const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
19
-
20
- export async function settingsRoutes(fastify) {
21
- const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
22
- const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
23
-
24
- fastify.get('/settings', canRead, async (request, reply) => {
25
- const config = getConfig('site');
26
- const safeConfig = { ...config };
27
- if (safeConfig.smtp) {
28
- safeConfig.smtp = { ...safeConfig.smtp, pass: '' };
29
- }
30
- return reply.send(safeConfig);
31
- });
32
-
33
- fastify.put('/settings', canUpdate, async (request, reply) => {
34
- const data = request.body;
35
- if (!data || typeof data !== 'object') {
36
- return reply.status(400).send({ error: 'Invalid settings data' });
37
- }
38
- const ALLOWED_KEYS = new Set([
39
- 'title', 'tagline', 'description', 'logo', 'favicon',
40
- 'theme', 'adminTheme', 'fontFamily', 'fontSize',
41
- 'smtp', 'footer', 'analytics', 'social', 'locale',
42
- 'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
43
- 'adminBrand'
44
- ]);
45
- const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
46
- if (unknownKeys.length > 0) {
47
- return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
48
- }
49
- // Merge incoming data with existing config so partial updates (e.g. just adminBrand)
50
- // don't wipe unrelated keys. Nested objects get a shallow merge so sub-fields are
51
- // preserved even when only some sub-fields are sent.
52
- const existing = getConfig('site');
53
- const merged = {...existing};
54
- for (const [k, v] of Object.entries(data)) {
55
- const isPlainObject = v !== null && typeof v === 'object' && !Array.isArray(v);
56
- const existingIsPlainObject = existing[k] !== null && typeof existing[k] === 'object' && !Array.isArray(existing[k]);
57
- if (isPlainObject && existingIsPlainObject) {
58
- merged[k] = {...existing[k], ...v};
59
- } else {
60
- merged[k] = v;
61
- }
62
- }
63
- // Never erase smtp.pass with the empty string that the GET endpoint injects for safety
64
- if (merged.smtp && merged.smtp.pass === '' && existing.smtp?.pass) {
65
- merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
66
- }
67
- saveConfig('site', merged);
68
- await cache.invalidateTags(['site']);
69
- return { success: true };
70
- });
71
-
72
- fastify.post('/settings/test-email', canRead, async (request, reply) => {
73
- const smtp = getConfig('site')?.smtp;
74
- if (!smtp?.host) {
75
- return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
76
- }
77
-
78
- const transporter = nodemailer.createTransport({
79
- host: smtp.host,
80
- port: smtp.port || 587,
81
- secure: smtp.secure || false,
82
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
83
- });
84
-
85
- const to = request.body?.to || smtp.fromAddress;
86
- if (!to) {
87
- return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
88
- }
89
-
90
- try {
91
- await transporter.sendMail({
92
- from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
93
- to,
94
- subject: 'Domma CMS — Test Email',
95
- text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
96
- html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
97
- });
98
- return { success: true, message: `Test email sent to ${to}` };
99
- } catch (err) {
100
- return reply.status(500).send({ error: `Failed to send email: ${err.message}` });
101
- }
102
- });
103
-
104
- // GET /api/settings/db-status — returns whether MongoDB connections are configured
105
- fastify.get('/settings/db-status', canRead, async () => {
106
- try {
107
- const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
108
- const connections = JSON.parse(raw);
109
- const names = Object.keys(connections).filter(k =>
110
- connections[k]?.type === 'mongodb' && connections[k]?.uri
111
- );
112
- return {configured: names.length > 0, connections: names};
113
- } catch {
114
- return {configured: false, connections: []};
115
- }
116
- });
117
-
118
- // GET /api/settings/custom-css — return current CSS as JSON
119
- fastify.get('/settings/custom-css', canUpdate, async () => {
120
- try {
121
- const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
122
- return { css };
123
- } catch {
124
- return { css: '' };
125
- }
126
- });
127
-
128
- // PUT /api/settings/custom-css — save CSS to content/custom.css
129
- fastify.put('/settings/custom-css', canUpdate, async (request, reply) => {
130
- const { css } = request.body || {};
131
- if (typeof css !== 'string') {
132
- return reply.status(400).send({ error: 'css must be a string.' });
133
- }
134
- if (Buffer.byteLength(css, 'utf8') > CUSTOM_CSS_MAX) {
135
- return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
136
- }
137
- await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
138
- await cache.invalidateTags(['site']);
139
- return { success: true };
140
- });
141
- }
1
+ /**
2
+ * Settings API
3
+ * GET /api/settings - get site settings
4
+ * PUT /api/settings - save site settings
5
+ * POST /api/settings/test-email - send a test email using stored SMTP config
6
+ */
7
+ import {getConfig, saveConfig} from '../../config.js';
8
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
9
+ import nodemailer from 'nodemailer';
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+ import {fileURLToPath} from 'url';
13
+ import * as cache from '../../services/cache/index.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
17
+ const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
18
+ const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
19
+
20
+ export async function settingsRoutes(fastify) {
21
+ const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
22
+ const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
23
+
24
+ fastify.get('/settings', canRead, async (request, reply) => {
25
+ const config = getConfig('site');
26
+ const safeConfig = { ...config };
27
+ if (safeConfig.smtp) {
28
+ safeConfig.smtp = { ...safeConfig.smtp, pass: '' };
29
+ }
30
+ return reply.send(safeConfig);
31
+ });
32
+
33
+ fastify.put('/settings', canUpdate, async (request, reply) => {
34
+ const data = request.body;
35
+ if (!data || typeof data !== 'object') {
36
+ return reply.status(400).send({ error: 'Invalid settings data' });
37
+ }
38
+ const ALLOWED_KEYS = new Set([
39
+ 'title', 'tagline', 'description', 'logo', 'favicon',
40
+ 'theme', 'adminTheme', 'fontFamily', 'fontSize',
41
+ 'smtp', 'footer', 'analytics', 'social', 'locale',
42
+ 'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
43
+ 'adminBrand'
44
+ ]);
45
+ const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
46
+ if (unknownKeys.length > 0) {
47
+ return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
48
+ }
49
+ // Merge incoming data with existing config so partial updates (e.g. just adminBrand)
50
+ // don't wipe unrelated keys. Nested objects get a shallow merge so sub-fields are
51
+ // preserved even when only some sub-fields are sent.
52
+ const existing = getConfig('site');
53
+ const merged = {...existing};
54
+ for (const [k, v] of Object.entries(data)) {
55
+ const isPlainObject = v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ const existingIsPlainObject = existing[k] !== null && typeof existing[k] === 'object' && !Array.isArray(existing[k]);
57
+ if (isPlainObject && existingIsPlainObject) {
58
+ merged[k] = {...existing[k], ...v};
59
+ } else {
60
+ merged[k] = v;
61
+ }
62
+ }
63
+ // Never erase smtp.pass with the empty string that the GET endpoint injects for safety
64
+ if (merged.smtp && merged.smtp.pass === '' && existing.smtp?.pass) {
65
+ merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
66
+ }
67
+ saveConfig('site', merged);
68
+ await cache.invalidateTags(['site']);
69
+ return { success: true };
70
+ });
71
+
72
+ fastify.post('/settings/test-email', canRead, async (request, reply) => {
73
+ const smtp = getConfig('site')?.smtp;
74
+ if (!smtp?.host) {
75
+ return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
76
+ }
77
+
78
+ const transporter = nodemailer.createTransport({
79
+ host: smtp.host,
80
+ port: smtp.port || 587,
81
+ secure: smtp.secure || false,
82
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
83
+ });
84
+
85
+ const to = request.body?.to || smtp.fromAddress;
86
+ if (!to) {
87
+ return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
88
+ }
89
+
90
+ try {
91
+ await transporter.sendMail({
92
+ from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
93
+ to,
94
+ subject: 'Domma CMS — Test Email',
95
+ text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
96
+ html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
97
+ });
98
+ return { success: true, message: `Test email sent to ${to}` };
99
+ } catch (err) {
100
+ return reply.status(500).send({ error: `Failed to send email: ${err.message}` });
101
+ }
102
+ });
103
+
104
+ // GET /api/settings/db-status — returns whether MongoDB connections are configured
105
+ fastify.get('/settings/db-status', canRead, async () => {
106
+ try {
107
+ const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
108
+ const connections = JSON.parse(raw);
109
+ const names = Object.keys(connections).filter(k =>
110
+ connections[k]?.type === 'mongodb' && connections[k]?.uri
111
+ );
112
+ return {configured: names.length > 0, connections: names};
113
+ } catch {
114
+ return {configured: false, connections: []};
115
+ }
116
+ });
117
+
118
+ // GET /api/settings/custom-css — return current CSS as JSON
119
+ fastify.get('/settings/custom-css', canUpdate, async () => {
120
+ try {
121
+ const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
122
+ return { css };
123
+ } catch {
124
+ return { css: '' };
125
+ }
126
+ });
127
+
128
+ // PUT /api/settings/custom-css — save CSS to content/custom.css
129
+ fastify.put('/settings/custom-css', canUpdate, async (request, reply) => {
130
+ const { css } = request.body || {};
131
+ if (typeof css !== 'string') {
132
+ return reply.status(400).send({ error: 'css must be a string.' });
133
+ }
134
+ if (Buffer.byteLength(css, 'utf8') > CUSTOM_CSS_MAX) {
135
+ return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
136
+ }
137
+ await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
138
+ await cache.invalidateTags(['site']);
139
+ return { success: true };
140
+ });
141
+ }
@@ -386,9 +386,64 @@ function substituteFilter(filterTemplate, params, query) {
386
386
  return out;
387
387
  }
388
388
 
389
+ /**
390
+ * Query keys that control the query envelope, not a field to filter on — they
391
+ * must never be interpreted as ad-hoc filters (otherwise `?limit=5` would
392
+ * filter a field called "limit").
393
+ */
394
+ const RESERVED_QUERY_KEYS = new Set(['page', 'limit', 'sort', 'order', 'search']);
395
+
396
+ /**
397
+ * The fields an endpoint is allowed to expose — and therefore the only fields
398
+ * an ad-hoc query filter may touch. A read-field allowlist, when set, is the
399
+ * boundary; otherwise every declared collection field (plus the always-safe
400
+ * `id` / `createdBy` pseudo-fields) is fair game. Restricting ad-hoc filters
401
+ * to exposed fields closes the boolean-oracle: a caller can never probe a
402
+ * field they cannot already see in the response.
403
+ *
404
+ * @param {object} def
405
+ * @returns {Promise<Set<string>>}
406
+ */
407
+ async function exposedFieldSet(def) {
408
+ if (Array.isArray(def.fields) && def.fields.length) return new Set(def.fields);
409
+ const schema = await getCollection(def.collection);
410
+ const names = (schema?.fields || []).map(f => f.name).filter(Boolean);
411
+ return new Set([...names, 'id', 'createdBy']);
412
+ }
413
+
414
+ /**
415
+ * Collect ad-hoc filters from the request query, layered UNDER the definition's
416
+ * declared filters: reserved envelope keys are skipped, values must target an
417
+ * exposed field, and a field already constrained by a declared filter is left
418
+ * to the declaration (the curated filter always wins — a caller cannot loosen
419
+ * or override it).
420
+ *
421
+ * @param {Record<string, unknown>} query
422
+ * @param {Set<string>} exposed - Filterable field names
423
+ * @param {Set<string>} declaredFields - Fields already fixed by the definition
424
+ * @returns {object} Ad-hoc filter clauses for the filterEngine
425
+ */
426
+ function collectAdHocFilter(query, exposed, declaredFields) {
427
+ const out = {};
428
+ for (const [key, value] of Object.entries(query || {})) {
429
+ if (RESERVED_QUERY_KEYS.has(key)) continue;
430
+ if (typeof value !== 'string' || value === '') continue;
431
+ const {field} = parseFilterKey(key);
432
+ if (!field) continue;
433
+ if (!exposed.has(field.split('.')[0])) continue; // can't filter what you can't see
434
+ if (declaredFields.has(field)) continue; // declared filter wins
435
+ out[key] = value;
436
+ }
437
+ return out;
438
+ }
439
+
389
440
  /**
390
441
  * Execute a matched endpoint definition.
391
442
  *
443
+ * The final filter is the definition's declared filters (fixed literals,
444
+ * {{params.x}}, {{query.x}}) with ad-hoc query-string filters layered beneath
445
+ * them — declared always wins on any overlap.
446
+ *
392
447
  * @param {object} def
393
448
  * @param {Record<string, string>} params - Decoded path params
394
449
  * @param {Record<string, string>} query - Request query object
@@ -396,12 +451,36 @@ function substituteFilter(filterTemplate, params, query) {
396
451
  * @throws {EndpointParamError}
397
452
  */
398
453
  export async function executeEndpoint(def, params, query) {
399
- const filter = substituteFilter(def.filter, params, query || {});
454
+ const q = query || {};
455
+ const exposed = await exposedFieldSet(def);
456
+ const declared = substituteFilter(def.filter, params, q);
457
+ const declaredFields = new Set(Object.keys(def.filter || {}).map(k => parseFilterKey(k).field));
458
+ const adhoc = collectAdHocFilter(q, exposed, declaredFields);
459
+ const filter = {...adhoc, ...declared}; // declared wins on any key collision
460
+
461
+ // Envelope controls — the reserved query keys tune pagination and sort
462
+ // rather than filter (list mode only). The definition's own limit is the
463
+ // ceiling: a caller may page/shrink within it but never pull more than the
464
+ // endpoint intends; sort is restricted to exposed fields.
465
+ let page = 1;
466
+ let limit = Number.isInteger(def.limit) ? def.limit : 50;
467
+ let sort = def.sort || 'createdAt';
468
+ let order = ORDERS.includes(def.order) ? def.order : 'desc';
469
+ if (def.mode !== 'single') {
470
+ const cap = limit > 0 ? limit : 500;
471
+ const qLimit = Number.parseInt(q.limit, 10);
472
+ if (Number.isInteger(qLimit) && qLimit >= 1) limit = Math.min(qLimit, cap);
473
+ const qPage = Number.parseInt(q.page, 10);
474
+ if (Number.isInteger(qPage) && qPage >= 1) page = qPage;
475
+ if (q.order === 'asc' || q.order === 'desc') order = q.order;
476
+ if (typeof q.sort === 'string' && q.sort && exposed.has(q.sort.split('.')[0])) sort = q.sort;
477
+ }
478
+
400
479
  const result = await listEntries(def.collection, {
401
- page: 1,
402
- limit: def.mode === 'single' ? 1 : (def.limit ?? 50),
403
- sort: def.sort || 'createdAt',
404
- order: def.order || 'desc',
480
+ page,
481
+ limit: def.mode === 'single' ? 1 : limit,
482
+ sort,
483
+ order,
405
484
  filter: Object.keys(filter).length ? filter : undefined
406
485
  });
407
486
  if (def.mode === 'single') return result.entries[0] || null;