domma-cms 0.23.0 → 0.24.0

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 (38) hide show
  1. package/CLAUDE.md +9 -0
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/crud-tutorial.js +1 -1
  5. package/admin/js/lib/project-context.js +1 -1
  6. package/admin/js/templates/api-tokens.html +13 -0
  7. package/admin/js/templates/effects.html +752 -752
  8. package/admin/js/templates/form-submissions.html +30 -30
  9. package/admin/js/templates/forms.html +17 -17
  10. package/admin/js/templates/my-profile.html +17 -17
  11. package/admin/js/templates/role-editor.html +70 -70
  12. package/admin/js/templates/roles.html +10 -10
  13. package/admin/js/views/api-tokens.js +8 -0
  14. package/admin/js/views/collection-editor.js +4 -4
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/roles.js +1 -1
  17. package/bin/lib/config-merge.js +44 -44
  18. package/bin/update.js +547 -547
  19. package/config/menus/admin-sidebar.json +7 -1
  20. package/package.json +1 -1
  21. package/server/middleware/auth.js +253 -253
  22. package/server/routes/api/api-tokens.js +83 -0
  23. package/server/routes/api/auth.js +309 -309
  24. package/server/routes/api/collections.js +113 -16
  25. package/server/routes/api/navigation.js +42 -42
  26. package/server/routes/api/settings.js +141 -141
  27. package/server/routes/public.js +202 -202
  28. package/server/server.js +8 -1
  29. package/server/services/apiTokens.js +259 -0
  30. package/server/services/email.js +167 -167
  31. package/server/services/permissionRegistry.js +13 -0
  32. package/server/services/presetCollections.js +25 -0
  33. package/server/services/roles.js +16 -0
  34. package/server/services/scaffolder.js +31 -1
  35. package/server/services/sidebar-migration.js +44 -0
  36. package/server/services/userProfiles.js +199 -199
  37. package/server/services/users.js +302 -302
  38. package/config/connections.json.bak +0 -9
@@ -22,6 +22,20 @@
22
22
  * POST /collections/:slug/public - Create entry (if api.create enabled)
23
23
  * PUT /collections/:slug/public/:id - Update entry (if api.update enabled)
24
24
  * DELETE /collections/:slug/public/:id - Delete entry (if api.delete enabled)
25
+ *
26
+ * External versioned alias (same handlers, stable URL for API consumers):
27
+ * GET /v1/:slug ≡ GET /collections/:slug/public
28
+ * GET /v1/:slug/:id ≡ GET /collections/:slug/public/:id
29
+ * POST /v1/:slug ≡ POST /collections/:slug/public
30
+ * PUT /v1/:slug/:id ≡ PUT /collections/:slug/public/:id
31
+ * DELETE /v1/:slug/:id ≡ DELETE /collections/:slug/public/:id
32
+ *
33
+ * Access modes per verb (`schema.api.<verb>.access`): 'public', a role name
34
+ * (JWT + role level), or 'token' (project-scoped API token — see
35
+ * services/apiTokens.js). A token is ONLY accepted when the mode is 'token';
36
+ * it is never a substitute for a role, and a JWT is never accepted in token
37
+ * mode. `schema.api.read.fields` optionally whitelists which data fields the
38
+ * public/external read endpoints return.
25
39
  */
26
40
  import {
27
41
  clearEntries,
@@ -45,6 +59,8 @@ import {getConfig, saveConfig} from '../../config.js';
45
59
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
46
60
  import {ensureFormForCollection} from '../../services/forms.js';
47
61
  import {hooks} from '../../services/hooks.js';
62
+ import {scopeAllows, validateToken} from '../../services/apiTokens.js';
63
+ import {resolveArtefactProject} from '../../services/projects.js';
48
64
 
49
65
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
50
66
 
@@ -82,6 +98,43 @@ function extractBracketed(query, prefix) {
82
98
  return out;
83
99
  }
84
100
 
101
+ /**
102
+ * Redact the stored token hash from an api-tokens entry. The generic admin
103
+ * entry endpoints would otherwise expose it to anyone with collections.read.
104
+ * (SHA-256 of 32 random bytes is not reversible, but there is no reason to
105
+ * show it.) Token management belongs to /api/api-tokens.
106
+ *
107
+ * @param {object} entry
108
+ * @returns {object}
109
+ */
110
+ function redactTokenHash(entry) {
111
+ if (!entry?.data || entry.data.tokenHash === undefined) return entry;
112
+ const { tokenHash, ...data } = entry.data;
113
+ return { ...entry, data };
114
+ }
115
+
116
+ /**
117
+ * Apply the read-field allowlist (`schema.api.read.fields`) to a public read
118
+ * payload. Absent or empty allowlist = all fields. Handles both list payloads
119
+ * ({entries: [...]}) and single entries. Only entry.data is filtered —
120
+ * `_refs` from resolveRefs is not (refs of stripped fields may still appear;
121
+ * documented v1 behaviour).
122
+ *
123
+ * @param {object} schema
124
+ * @param {object} payload - listEntries() result or a single entry
125
+ * @returns {object}
126
+ */
127
+ function applyReadFieldAllowlist(schema, payload) {
128
+ const fields = schema.api?.read?.fields;
129
+ if (!Array.isArray(fields) || fields.length === 0) return payload;
130
+ const strip = (e) => ({
131
+ ...e,
132
+ data: Object.fromEntries(Object.entries(e.data || {}).filter(([k]) => fields.includes(k)))
133
+ });
134
+ if (Array.isArray(payload?.entries)) return { ...payload, entries: payload.entries.map(strip) };
135
+ return strip(payload);
136
+ }
137
+
85
138
  /**
86
139
  * Check public collection API access.
87
140
  * Returns an error reply if access is denied, otherwise resolves (returns undefined).
@@ -100,6 +153,28 @@ async function checkPublicAccess(schema, operation, request, reply) {
100
153
 
101
154
  if (access.access === 'public') return; // No auth needed
102
155
 
156
+ // Token mode — project-scoped API token, strict: a token is the ONLY
157
+ // accepted credential here (a JWT never satisfies token mode, and a
158
+ // token never satisfies a role mode).
159
+ if (access.access === 'token') {
160
+ const match = (request.headers.authorization || '').match(/^Bearer (dcms_[a-f0-9]{64})$/);
161
+ if (!match) {
162
+ return reply.status(401).send({ error: 'API token required' });
163
+ }
164
+ const token = await validateToken(match[1]);
165
+ if (!token) {
166
+ return reply.status(401).send({ error: 'Invalid, disabled or expired API token' });
167
+ }
168
+ if (token.project !== resolveArtefactProject(schema)) {
169
+ return reply.status(403).send({ error: "Token is not valid for this collection's project" });
170
+ }
171
+ if (!scopeAllows(token.scopes, schema.slug, operation)) {
172
+ return reply.status(403).send({ error: 'Token scope does not permit this operation' });
173
+ }
174
+ request.apiToken = token;
175
+ return;
176
+ }
177
+
103
178
  // Auth required — try to verify JWT
104
179
  try {
105
180
  await request.jwtVerify();
@@ -240,7 +315,7 @@ export async function collectionsRoutes(fastify) {
240
315
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
241
316
  const { page, limit, sort, order, search } = request.query;
242
317
  const filter = extractBracketed(request.query, 'filter');
243
- return listEntries(request.params.slug, {
318
+ const result = await listEntries(request.params.slug, {
244
319
  page: parseInt(page, 10) || 1,
245
320
  limit: parseInt(limit, 10) || 50,
246
321
  sort: sort || 'createdAt',
@@ -248,12 +323,16 @@ export async function collectionsRoutes(fastify) {
248
323
  search: search || undefined,
249
324
  filter: Object.keys(filter).length ? filter : undefined
250
325
  });
326
+ if (request.params.slug === 'api-tokens') {
327
+ result.entries = result.entries.map(redactTokenHash);
328
+ }
329
+ return result;
251
330
  });
252
331
 
253
332
  fastify.get('/collections/:slug/entries/:id', canRead, async (request, reply) => {
254
333
  const entry = await getEntry(request.params.slug, request.params.id);
255
334
  if (!entry) return reply.status(404).send({ error: 'Entry not found' });
256
- return entry;
335
+ return request.params.slug === 'api-tokens' ? redactTokenHash(entry) : entry;
257
336
  });
258
337
 
259
338
  fastify.post('/collections/:slug/entries', canCreate, async (request, reply) => {
@@ -415,7 +494,7 @@ export async function collectionsRoutes(fastify) {
415
494
  * `[collection scope="mine"]` shortcode for per-user
416
495
  * client-side hydration (e.g. "My Applications").
417
496
  */
418
- fastify.get('/collections/:slug/public', async (request, reply) => {
497
+ async function publicListEntries(request, reply) {
419
498
  const schema = await getCollection(request.params.slug);
420
499
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
421
500
 
@@ -442,7 +521,7 @@ export async function collectionsRoutes(fastify) {
442
521
  if (denied !== undefined) return;
443
522
  }
444
523
 
445
- return listEntries(request.params.slug, {
524
+ const result = await listEntries(request.params.slug, {
446
525
  page: parseInt(page, 10) || 1,
447
526
  limit: parseInt(limit, 10) || 50,
448
527
  sort: sort || 'createdAt',
@@ -451,9 +530,10 @@ export async function collectionsRoutes(fastify) {
451
530
  filter: Object.keys(filter).length ? filter : undefined,
452
531
  resolveRefs: resolveRefs === 'true' || resolveRefs === '1'
453
532
  });
454
- });
533
+ return applyReadFieldAllowlist(schema, result);
534
+ }
455
535
 
456
- fastify.get('/collections/:slug/public/:id', async (request, reply) => {
536
+ async function publicGetEntry(request, reply) {
457
537
  const schema = await getCollection(request.params.slug);
458
538
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
459
539
 
@@ -462,8 +542,11 @@ export async function collectionsRoutes(fastify) {
462
542
 
463
543
  const entry = await getEntry(request.params.slug, request.params.id);
464
544
  if (!entry) return reply.status(404).send({ error: 'Entry not found' });
465
- return entry;
466
- });
545
+ return applyReadFieldAllowlist(schema, entry);
546
+ }
547
+
548
+ fastify.get('/collections/:slug/public', publicListEntries);
549
+ fastify.get('/collections/:slug/public/:id', publicGetEntry);
467
550
 
468
551
  /*
469
552
  * POST /api/collections/render-scope
@@ -567,7 +650,7 @@ export async function collectionsRoutes(fastify) {
567
650
  return { html };
568
651
  });
569
652
 
570
- fastify.post('/collections/:slug/public', async (request, reply) => {
653
+ async function publicCreateEntry(request, reply) {
571
654
  const schema = await getCollection(request.params.slug);
572
655
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
573
656
 
@@ -575,18 +658,17 @@ export async function collectionsRoutes(fastify) {
575
658
  if (denied !== undefined) return;
576
659
 
577
660
  try {
578
- const user = request.user;
579
661
  const entry = await createEntry(request.params.slug, request.body?.data || {}, {
580
- createdBy: user?.id || null,
662
+ createdBy: request.apiToken ? `token:${request.apiToken.id}` : (request.user?.id || null),
581
663
  source: 'api'
582
664
  });
583
665
  return reply.status(201).send(entry);
584
666
  } catch (err) {
585
667
  return reply.status(400).send({ error: err.message });
586
668
  }
587
- });
669
+ }
588
670
 
589
- fastify.put('/collections/:slug/public/:id', async (request, reply) => {
671
+ async function publicUpdateEntry(request, reply) {
590
672
  const schema = await getCollection(request.params.slug);
591
673
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
592
674
 
@@ -599,9 +681,9 @@ export async function collectionsRoutes(fastify) {
599
681
  const status = err.message === 'Entry not found' ? 404 : 400;
600
682
  return reply.status(status).send({ error: err.message });
601
683
  }
602
- });
684
+ }
603
685
 
604
- fastify.delete('/collections/:slug/public/:id', async (request, reply) => {
686
+ async function publicDeleteEntry(request, reply) {
605
687
  const schema = await getCollection(request.params.slug);
606
688
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
607
689
 
@@ -614,5 +696,20 @@ export async function collectionsRoutes(fastify) {
614
696
  } catch (err) {
615
697
  return reply.status(404).send({ error: err.message });
616
698
  }
617
- });
699
+ }
700
+
701
+ fastify.post('/collections/:slug/public', publicCreateEntry);
702
+ fastify.put('/collections/:slug/public/:id', publicUpdateEntry);
703
+ fastify.delete('/collections/:slug/public/:id', publicDeleteEntry);
704
+
705
+ // -------------------------------------------------------------------------
706
+ // External versioned API — stable alias of the public endpoints above.
707
+ // Documented surface for external consumers (docs/api-reference.md).
708
+ // -------------------------------------------------------------------------
709
+
710
+ fastify.get('/v1/:slug', publicListEntries);
711
+ fastify.get('/v1/:slug/:id', publicGetEntry);
712
+ fastify.post('/v1/:slug', publicCreateEntry);
713
+ fastify.put('/v1/:slug/:id', publicUpdateEntry);
714
+ fastify.delete('/v1/:slug/:id', publicDeleteEntry);
618
715
  }
@@ -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
+ }