domma-cms 0.25.16 → 0.30.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.
@@ -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
+ }
@@ -80,6 +80,10 @@ export async function projectsRoutes(fastify, opts = {}) {
80
80
  if (request.params.slug === CORE_PROJECT_SLUG) {
81
81
  return reply.status(403).send({error: 'Cannot delete the core project'});
82
82
  }
83
+ const target = await getProject(request.params.slug);
84
+ if (target?.protected === true) {
85
+ return reply.status(403).send({error: 'This project is protected and cannot be deleted'});
86
+ }
83
87
  const grouped = await getArtefactsForProject(request.params.slug);
84
88
  const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
85
89
  if (total > 0) {
@@ -105,7 +109,11 @@ export async function projectsRoutes(fastify, opts = {}) {
105
109
  if (request.params.slug === CORE_PROJECT_SLUG) {
106
110
  return reply.status(403).send({error: 'Cannot untag the core project — artefacts would immediately resolve back to it'});
107
111
  }
108
- if (!(await getProject(request.params.slug))) {
112
+ const target = await getProject(request.params.slug);
113
+ if (target?.protected === true) {
114
+ return reply.status(403).send({error: 'This project is protected and cannot be untagged'});
115
+ }
116
+ if (!target) {
109
117
  return reply.status(404).send({error: 'Project not found'});
110
118
  }
111
119
  const counts = await untagAllForProject(request.params.slug);
@@ -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
+ }
@@ -5,6 +5,7 @@
5
5
  * The admin panel is excluded (handled by static serving).
6
6
  */
7
7
  import {getPage} from '../services/content.js';
8
+ import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
8
9
  import {renderPage} from '../services/renderer.js';
9
10
  import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
10
11
  import {checkVisibility} from '../middleware/auth.js';
@@ -108,6 +109,14 @@ export async function publicRoutes(fastify) {
108
109
  return reply.type('text/html').send(await render404(urlPath));
109
110
  }
110
111
 
112
+ // Hard kill-switch: a disabled project takes its pages off the public
113
+ // site entirely — 404 (no oracle), regardless of page visibility.
114
+ const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
115
+ if (!(await isProjectEnabled(pageProject))) {
116
+ reply.status(404);
117
+ return reply.type('text/html').send(await render404(urlPath));
118
+ }
119
+
111
120
  // Enforce page visibility — role only resolved for gated pages,
112
121
  // so public pages share a single cache entry keyed `roleanon`.
113
122
  //
package/server/server.js CHANGED
@@ -22,7 +22,7 @@ import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/p
22
22
  import {load as loadRoles, seed as seedRoles} from './services/roles.js';
23
23
  import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
24
24
  import {seedAll as seedPresetCollections} from './services/presetCollections.js';
25
- import {seedCoreProject} from './services/projects.js';
25
+ import {seedCoreProject, seedDocsProject} from './services/projects.js';
26
26
  import {seedDefaultBlocks} from './services/blocks.js';
27
27
  import {seedDefaultComponents} from './services/components.js';
28
28
  import {refreshComponentTagAllowlist} from './services/markdown.js';
@@ -186,6 +186,7 @@ await seedUserProfiles();
186
186
  await ensureAllProfiles();
187
187
  await seedPresetCollections();
188
188
  await seedCoreProject();
189
+ await seedDocsProject(); // built-in Domma Docs project — seeded disabled by default
189
190
  await seedDefaultBlocks();
190
191
  await seedDefaultComponents();
191
192
  await refreshComponentTagAllowlist();
@@ -395,3 +396,4 @@ try {
395
396
  process.exit(1);
396
397
  }
397
398
  // scaffold-restart 1779615684
399
+