domma-cms 0.30.0 → 0.32.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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/js/lib/themes.js +1 -1
- package/admin/js/templates/effects.html +752 -752
- 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/settings.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/config/site.json +1 -0
- package/package.json +2 -2
- package/plugins/surveys/admin/templates/audience.html +47 -0
- package/plugins/surveys/admin/templates/results.html +58 -0
- package/plugins/surveys/admin/templates/survey-editor.html +141 -0
- package/plugins/surveys/admin/templates/surveys.html +25 -0
- package/plugins/surveys/admin/views/audience.js +301 -0
- package/plugins/surveys/admin/views/results.js +172 -0
- package/plugins/surveys/admin/views/survey-editor.js +211 -0
- package/plugins/surveys/admin/views/surveys.js +161 -0
- package/plugins/surveys/collections/survey-contacts/schema.json +13 -0
- package/plugins/surveys/collections/survey-groups/schema.json +11 -0
- package/plugins/surveys/collections/survey-invites/schema.json +16 -0
- package/plugins/surveys/collections/surveys/schema.json +23 -0
- package/plugins/surveys/config.js +8 -0
- package/plugins/surveys/plugin.js +169 -0
- package/plugins/surveys/plugin.json +65 -0
- package/plugins/surveys/public/dist-shared.mjs +1 -0
- package/plugins/surveys/public/survey.css +1 -0
- package/plugins/surveys/public/survey.mjs +1 -0
- package/plugins/surveys/templates/survey-page.html +63 -0
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/routes/docs-public.js +43 -0
- package/server/routes/public.js +2 -2
- package/server/server.js +13 -1
- package/{scripts/migrate-docs.js → server/services/docs.js} +204 -126
- package/server/services/email.js +167 -167
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/config/connections.json.bak +0 -9
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domma Docs — public surface
|
|
3
|
+
*
|
|
4
|
+
* Serves the handbook at request time from the admin doc templates:
|
|
5
|
+
* GET /domma-docs → home landing
|
|
6
|
+
* GET /domma-docs/<path> → section landing or leaf page
|
|
7
|
+
*
|
|
8
|
+
* The publish switch is the `domma-docs` project's enabled flag: when the
|
|
9
|
+
* project is disabled the whole surface 404s (no existence oracle), identical
|
|
10
|
+
* to the catch-all's project kill-switch. Unknown paths 404 the same way.
|
|
11
|
+
*
|
|
12
|
+
* This dedicated /domma-docs(/*) prefix out-prioritises public.js's `/*`
|
|
13
|
+
* catch-all (Fastify ranks static segments above wildcards), exactly as
|
|
14
|
+
* routes/api/endpoints-public.js `/x/*` coexists with it — so no per-path
|
|
15
|
+
* special-casing is needed in public.js. Registered at app root (not behind a
|
|
16
|
+
* prefixed plugin) so the specificity ranking shares one router scope.
|
|
17
|
+
*/
|
|
18
|
+
import {isProjectEnabled} from '../services/projects.js';
|
|
19
|
+
import {renderDocPage} from '../services/docs.js';
|
|
20
|
+
import {getBaseUrl, render404} from './public.js';
|
|
21
|
+
|
|
22
|
+
const DOCS_PROJECT_SLUG = 'domma-docs';
|
|
23
|
+
|
|
24
|
+
export async function docsPublicRoutes(fastify) {
|
|
25
|
+
const handle = async (request, reply) => {
|
|
26
|
+
// Hard kill-switch: docs disabled → 404 the entire surface.
|
|
27
|
+
if (!(await isProjectEnabled(DOCS_PROJECT_SLUG))) {
|
|
28
|
+
reply.status(404);
|
|
29
|
+
return reply.type('text/html').send(await render404(request.url.split('?')[0]));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const docPath = String(request.params['*'] || '');
|
|
33
|
+
const html = await renderDocPage(docPath, {user: null, baseUrl: getBaseUrl(request)});
|
|
34
|
+
if (html == null) {
|
|
35
|
+
reply.status(404);
|
|
36
|
+
return reply.type('text/html').send(await render404(`/domma-docs${docPath ? '/' + docPath : ''}`));
|
|
37
|
+
}
|
|
38
|
+
return reply.type('text/html').send(html);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
fastify.get('/domma-docs', handle); // exact — params['*'] === '' (home landing)
|
|
42
|
+
fastify.get('/domma-docs/*', handle); // sections + leaves
|
|
43
|
+
}
|
package/server/routes/public.js
CHANGED
|
@@ -26,7 +26,7 @@ import * as cache from '../services/cache/index.js';
|
|
|
26
26
|
* @param {import('fastify').FastifyRequest} request
|
|
27
27
|
* @returns {string} e.g. 'https://example.com' (no trailing slash)
|
|
28
28
|
*/
|
|
29
|
-
function getBaseUrl(request) {
|
|
29
|
+
export function getBaseUrl(request) {
|
|
30
30
|
const override = (getConfig('site') || {}).baseUrl;
|
|
31
31
|
if (override) return override.toString().trim().replace(/\/+$/, '');
|
|
32
32
|
const host = request.host || request.headers.host || request.hostname;
|
|
@@ -178,7 +178,7 @@ export async function publicRoutes(fastify) {
|
|
|
178
178
|
* Render a 404 response — tries content/pages/404.md first, falls back to
|
|
179
179
|
* a minimal inline page so the site theme is applied when possible.
|
|
180
180
|
*/
|
|
181
|
-
async function render404(urlPath) {
|
|
181
|
+
export async function render404(urlPath) {
|
|
182
182
|
try {
|
|
183
183
|
const page404 = await getPage('/404');
|
|
184
184
|
if (page404 && page404.status === 'published') {
|
package/server/server.js
CHANGED
|
@@ -224,6 +224,11 @@ try {
|
|
|
224
224
|
await cache.initCache(config.cache);
|
|
225
225
|
console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
|
|
226
226
|
|
|
227
|
+
// Docs are rendered at request time from the shipped admin templates; clear any
|
|
228
|
+
// previously-cached docs HTML so a version bump (new templates) never serves
|
|
229
|
+
// stale pages. No-op on a cold memory cache; matters for the Redis driver.
|
|
230
|
+
await cache.invalidateTags(['docs']);
|
|
231
|
+
|
|
227
232
|
// Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
|
|
228
233
|
await app.register(staticPlugin, {
|
|
229
234
|
root: mediaDir,
|
|
@@ -359,7 +364,9 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
|
|
|
359
364
|
await app.register(publicPlugin, {
|
|
360
365
|
settings,
|
|
361
366
|
auth: { authenticate: _authenticate, requireAdmin: _requireAdmin, requireVisibility: _requireVisibility },
|
|
362
|
-
|
|
367
|
+
// getConfig() requires a name; pass the startup config singleton instead.
|
|
368
|
+
// (Without this every enabled plugin.public.js fails to register.)
|
|
369
|
+
config
|
|
363
370
|
});
|
|
364
371
|
} catch (err) {
|
|
365
372
|
app.log.error(`[plugins] Failed to register public plugin ${name}: ${err.message}`);
|
|
@@ -370,6 +377,11 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
|
|
|
370
377
|
// Public Site (catch-all — must be last)
|
|
371
378
|
// ---------------------------------------------------------------------------
|
|
372
379
|
|
|
380
|
+
// Domma Docs (request-time handbook) — dedicated /domma-docs(/*) prefix that
|
|
381
|
+
// out-prioritises the catch-all; must be registered before it.
|
|
382
|
+
const { docsPublicRoutes } = await import('./routes/docs-public.js');
|
|
383
|
+
await app.register(docsPublicRoutes);
|
|
384
|
+
|
|
373
385
|
const { publicRoutes } = await import('./routes/public.js');
|
|
374
386
|
await app.register(publicRoutes);
|
|
375
387
|
|