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.
- package/CLAUDE.md +9 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/templates/api-tokens.html +13 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-submissions.html +30 -30
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-tokens.js +8 -0
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/roles.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +547 -547
- package/config/menus/admin-sidebar.json +7 -1
- package/package.json +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +113 -16
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +8 -1
- package/server/services/apiTokens.js +259 -0
- package/server/services/email.js +167 -167
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/presetCollections.js +25 -0
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +31 -1
- package/server/services/sidebar-migration.js +44 -0
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|