domma-cms 0.10.0 → 0.12.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 +248 -159
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +7 -3
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/http-interceptor.js +1 -0
- package/admin/js/lib/safe-html.js +1 -0
- package/admin/js/templates/layouts.html +5 -4
- package/admin/js/templates/notifications.html +14 -0
- package/admin/js/templates/plugin-marketplace.html +16 -0
- package/admin/js/templates/plugins.html +17 -5
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +1 -16
- package/admin/js/views/notifications.js +1 -0
- package/admin/js/views/plugin-marketplace.js +1 -0
- package/admin/js/views/plugins.js +16 -16
- package/config/navigation.json +5 -72
- package/config/plugins.json +10 -14
- package/config/presets.json +50 -13
- package/config/site.json +11 -63
- package/package.json +2 -1
- package/plugins/_template/admin/templates/index.html +17 -0
- package/plugins/_template/admin/views/index.js +19 -0
- package/plugins/_template/config.js +8 -0
- package/plugins/_template/plugin.js +23 -0
- package/plugins/_template/plugin.json +34 -0
- package/plugins/analytics/plugin.json +41 -31
- package/plugins/blog/admin/templates/blog.html +22 -0
- package/plugins/blog/admin/templates/categories.html +7 -0
- package/plugins/blog/admin/templates/comments.html +11 -0
- package/plugins/blog/admin/templates/post-editor.html +97 -0
- package/plugins/blog/admin/templates/settings.html +11 -0
- package/plugins/blog/admin/views/blog.js +183 -0
- package/plugins/blog/admin/views/categories.js +235 -0
- package/plugins/blog/admin/views/comments.js +187 -0
- package/plugins/blog/admin/views/post-editor.js +291 -0
- package/plugins/blog/admin/views/settings.js +100 -0
- package/plugins/blog/collections/categories/schema.json +12 -0
- package/plugins/blog/collections/comments/schema.json +16 -0
- package/plugins/blog/collections/posts/schema.json +19 -0
- package/plugins/blog/config.js +8 -0
- package/plugins/blog/plugin.js +352 -0
- package/plugins/blog/plugin.json +96 -0
- package/plugins/blog/roles/blog-author.json +10 -0
- package/plugins/blog/roles/blog-editor.json +12 -0
- package/plugins/blog/templates/author.html +9 -0
- package/plugins/blog/templates/category.html +9 -0
- package/plugins/blog/templates/index.html +9 -0
- package/plugins/blog/templates/post.html +17 -0
- package/plugins/blog/templates/tag.html +9 -0
- package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
- package/plugins/contacts/collections/user-contacts/schema.json +1 -1
- package/plugins/contacts/plugin.js +4 -10
- package/plugins/contacts/plugin.json +13 -3
- package/plugins/notes/collections/user-notes/schema.json +1 -1
- package/plugins/notes/plugin.js +3 -9
- package/plugins/notes/plugin.json +13 -3
- package/plugins/site-search/plugin.json +5 -2
- package/plugins/theme-switcher/plugin.json +1 -1
- package/plugins/todo/collections/todos/schema.json +1 -1
- package/plugins/todo/plugin.js +3 -9
- package/plugins/todo/plugin.json +13 -3
- package/public/css/site.css +1 -1
- package/scripts/build.js +48 -0
- package/scripts/create-plugin.js +113 -0
- package/scripts/fresh.js +6 -7
- package/scripts/gen-instance-secret.js +46 -0
- package/scripts/reset.js +3 -3
- package/scripts/setup.js +31 -13
- package/server/middleware/auth.js +48 -0
- package/server/middleware/managerAuth.js +36 -0
- package/server/routes/api/actions.js +1 -1
- package/server/routes/api/auth.js +4 -3
- package/server/routes/api/layouts.js +173 -49
- package/server/routes/api/notifications.js +155 -0
- package/server/routes/api/plugin-marketplace.js +75 -0
- package/server/routes/api/users.js +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/routes/public.js +4 -9
- package/server/server.js +32 -3
- package/server/services/actions.js +1 -1
- package/server/services/managerClient.js +182 -0
- package/server/services/permissionRegistry.js +245 -173
- package/server/services/pluginInstaller.js +301 -0
- package/server/services/plugins.js +117 -10
- package/server/services/presetCollections.js +66 -251
- package/server/services/renderer.js +99 -0
- package/server/services/roles.js +191 -39
- package/server/services/users.js +1 -1
- package/server/services/views.js +1 -1
- package/server/templates/page.html +2 -2
- package/plugins/docs/admin/templates/docs.html +0 -69
- package/plugins/docs/admin/views/docs.js +0 -276
- package/plugins/docs/config.js +0 -8
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
- package/plugins/docs/data/folders.json +0 -9
- package/plugins/docs/data/templates.json +0 -1
- package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
- package/plugins/docs/plugin.js +0 -375
- package/plugins/docs/plugin.json +0 -23
- package/plugins/form-builder/data/forms/contacts.json +0 -66
- package/plugins/form-builder/data/forms/enquiries.json +0 -103
- package/plugins/form-builder/data/forms/feedback.json +0 -131
- package/plugins/form-builder/data/forms/notes.json +0 -79
- package/plugins/form-builder/data/forms/to-do.json +0 -100
- package/plugins/form-builder/data/submissions/contacts.json +0 -1
- package/plugins/form-builder/data/submissions/enquiries.json +0 -1
- package/plugins/form-builder/data/submissions/feedback.json +0 -1
- package/plugins/form-builder/data/submissions/notes.json +0 -1
- package/plugins/form-builder/data/submissions/to-do.json +0 -1
- package/plugins/garage/admin/templates/garage.html +0 -111
- package/plugins/garage/admin/views/garage.js +0 -622
- package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
- package/plugins/garage/config.js +0 -18
- package/plugins/garage/data/vehicles.json +0 -70
- package/plugins/garage/plugin.js +0 -398
- package/plugins/garage/plugin.json +0 -33
- package/scripts/seed.js +0 -1996
- package/server/services/userTypes.js +0 -227
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manager auth middleware — shared-secret guard for inbound requests from domma-cms-manager.
|
|
3
|
+
* Completely separate from JWT authenticate. Do NOT mix these two auth surfaces.
|
|
4
|
+
*/
|
|
5
|
+
import { timingSafeEqual, createHmac } from 'crypto';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fastify preHandler — validates the X-Manager-Token or Authorization: Bearer header
|
|
9
|
+
* against the MANAGER_SECRET environment variable using timing-safe comparison.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('fastify').FastifyRequest} request
|
|
12
|
+
* @param {import('fastify').FastifyReply} reply
|
|
13
|
+
* @param {Function} done
|
|
14
|
+
*/
|
|
15
|
+
export function requireManager(request, reply, done) {
|
|
16
|
+
const secret = process.env.MANAGER_SECRET;
|
|
17
|
+
|
|
18
|
+
if (!secret || secret.length < 32) {
|
|
19
|
+
return reply.code(503).send({ error: 'Manager push notifications are not configured on this server.' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const authHeader = request.headers['x-manager-token'] || request.headers['authorization'] || '';
|
|
23
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader;
|
|
24
|
+
|
|
25
|
+
if (!token) {
|
|
26
|
+
return reply.code(401).send({ error: 'Manager token required.' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Compare HMAC digests — always 32 bytes regardless of input length, preventing timing oracle
|
|
30
|
+
const digest = (s) => createHmac('sha256', 'domma-cms').update(s).digest();
|
|
31
|
+
if (!timingSafeEqual(digest(token), digest(secret))) {
|
|
32
|
+
return reply.code(403).send({ error: 'Invalid manager token.' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
done();
|
|
36
|
+
}
|
|
@@ -178,7 +178,7 @@ export async function actionsRoutes(fastify) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
const user = request.user;
|
|
181
|
-
const allowedRoles = action.access?.roles || ['admin'];
|
|
181
|
+
const allowedRoles = action.access?.roles || ['admin', 'super-admin'];
|
|
182
182
|
const userLevel = getRoleLevel(user?.role);
|
|
183
183
|
const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
|
|
184
184
|
|
|
@@ -11,6 +11,7 @@ import crypto from 'node:crypto';
|
|
|
11
11
|
import {config, getConfig} from '../../config.js';
|
|
12
12
|
import {hooks} from '../../services/hooks.js';
|
|
13
13
|
import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
|
|
14
|
+
import {getRoleLevel} from '../../services/roles.js';
|
|
14
15
|
import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
|
|
15
16
|
import {
|
|
16
17
|
clearResetToken,
|
|
@@ -80,7 +81,7 @@ export async function authRoutes(fastify) {
|
|
|
80
81
|
|
|
81
82
|
setupInProgress = true;
|
|
82
83
|
try {
|
|
83
|
-
const user = await createUser({name, email, password, role: 'admin'});
|
|
84
|
+
const user = await createUser({name, email, password, role: 'super-admin'});
|
|
84
85
|
const {token, refreshToken} = signTokens(fastify, user);
|
|
85
86
|
return reply.status(201).send({token, refreshToken, user});
|
|
86
87
|
} finally {
|
|
@@ -107,7 +108,7 @@ export async function authRoutes(fastify) {
|
|
|
107
108
|
|
|
108
109
|
await touchLastLogin(user.id);
|
|
109
110
|
|
|
110
|
-
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
111
|
+
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, level: getRoleLevel(user.role) };
|
|
111
112
|
hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
|
|
112
113
|
const { token, refreshToken } = signTokens(fastify, safeUser);
|
|
113
114
|
return { token, refreshToken, user: safeUser };
|
|
@@ -270,7 +271,7 @@ export async function authRoutes(fastify) {
|
|
|
270
271
|
return reply.status(401).send({ error: 'User not found or inactive' });
|
|
271
272
|
}
|
|
272
273
|
|
|
273
|
-
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
274
|
+
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, level: getRoleLevel(user.role) };
|
|
274
275
|
const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
|
|
275
276
|
return { token };
|
|
276
277
|
});
|
|
@@ -1,49 +1,173 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layouts (Presets) API
|
|
3
|
-
* GET
|
|
4
|
-
* PUT
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Layouts (Presets) API
|
|
3
|
+
* GET /api/layouts - get all layout presets
|
|
4
|
+
* PUT /api/layouts - bulk-save layout presets (legacy, kept for compatibility)
|
|
5
|
+
* POST /api/layouts - create a new preset
|
|
6
|
+
* PUT /api/layouts/:key - update a single preset
|
|
7
|
+
* DELETE /api/layouts/:key - delete a preset (builtin presets cannot be deleted)
|
|
8
|
+
* GET /api/layouts/options - get layout options
|
|
9
|
+
* PUT /api/layouts/options - save layout options
|
|
10
|
+
*/
|
|
11
|
+
import {getConfig, saveConfig} from '../../config.js';
|
|
12
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
13
|
+
|
|
14
|
+
const VALID_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
|
|
15
|
+
const BG_COLOR_RE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
|
|
16
|
+
const CLASS_RE = /[^a-zA-Z0-9\s_-]/g;
|
|
17
|
+
|
|
18
|
+
const BUILTIN_PRESETS = {
|
|
19
|
+
default: {
|
|
20
|
+
key: 'default', label: 'Default',
|
|
21
|
+
description: 'Standard page with navbar and footer.',
|
|
22
|
+
builtin: true, navbar: true, footer: true, sidebar: false,
|
|
23
|
+
width: 'normal', bgColor: '', bgImage: '', class: ''
|
|
24
|
+
},
|
|
25
|
+
landing: {
|
|
26
|
+
key: 'landing', label: 'Landing Page',
|
|
27
|
+
description: 'Full-width landing page with navbar, no footer.',
|
|
28
|
+
builtin: true, navbar: true, footer: false, sidebar: false,
|
|
29
|
+
width: 'full', bgColor: '', bgImage: '', class: ''
|
|
30
|
+
},
|
|
31
|
+
blank: {
|
|
32
|
+
key: 'blank', label: 'Blank',
|
|
33
|
+
description: 'Minimal page with no navbar or footer.',
|
|
34
|
+
builtin: true, navbar: false, footer: false, sidebar: false,
|
|
35
|
+
width: 'normal', bgColor: '', bgImage: '', class: ''
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function slugify(str) {
|
|
40
|
+
return str.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitisePreset(data) {
|
|
44
|
+
return {
|
|
45
|
+
navbar: data.navbar !== false,
|
|
46
|
+
footer: data.footer !== false,
|
|
47
|
+
sidebar: data.sidebar === true,
|
|
48
|
+
width: VALID_WIDTHS.has(data.width) ? data.width : 'normal',
|
|
49
|
+
bgColor: data.bgColor && BG_COLOR_RE.test(data.bgColor) ? data.bgColor : '',
|
|
50
|
+
bgImage: typeof data.bgImage === 'string' ? data.bgImage.trim() : '',
|
|
51
|
+
class: typeof data.class === 'string' ? data.class.replace(CLASS_RE, '').trim() : '',
|
|
52
|
+
label: String(data.label || '').slice(0, 60).trim(),
|
|
53
|
+
description: String(data.description || '').slice(0, 200).trim()
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function layoutsRoutes(fastify) {
|
|
58
|
+
// ── Merge-in seeding: ensure built-in presets exist ──────────────────────
|
|
59
|
+
const existing = getConfig('presets') || {};
|
|
60
|
+
let seeded = false;
|
|
61
|
+
for (const [key, preset] of Object.entries(BUILTIN_PRESETS)) {
|
|
62
|
+
if (!existing[key]) {
|
|
63
|
+
existing[key] = preset;
|
|
64
|
+
seeded = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (seeded) saveConfig('presets', existing);
|
|
68
|
+
|
|
69
|
+
const canRead = {preHandler: [authenticate, requirePermission('layouts', 'read')]};
|
|
70
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('layouts', 'update')]};
|
|
71
|
+
|
|
72
|
+
// ── Existing routes ───────────────────────────────────────────────────────
|
|
73
|
+
fastify.get('/layouts', canRead, async () => {
|
|
74
|
+
return getConfig('presets');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
fastify.put('/layouts', canUpdate, async (request, reply) => {
|
|
78
|
+
const data = request.body;
|
|
79
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
80
|
+
return reply.status(400).send({error: 'Invalid presets data: expected an object'});
|
|
81
|
+
}
|
|
82
|
+
for (const [key, preset] of Object.entries(data)) {
|
|
83
|
+
if (!preset || typeof preset !== 'object' || Array.isArray(preset)) {
|
|
84
|
+
return reply.status(400).send({error: `Invalid preset "${key}": expected an object`});
|
|
85
|
+
}
|
|
86
|
+
if (typeof preset.label !== 'string' || !preset.label.trim()) {
|
|
87
|
+
return reply.status(400).send({error: `Invalid preset "${key}": missing or empty "label" field`});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
saveConfig('presets', data);
|
|
91
|
+
return {success: true};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── New CRUD routes ───────────────────────────────────────────────────────
|
|
95
|
+
fastify.post('/layouts', canUpdate, async (request, reply) => {
|
|
96
|
+
const {label, description, ...rest} = request.body || {};
|
|
97
|
+
if (!label || !String(label).trim()) {
|
|
98
|
+
return reply.status(400).send({error: 'label is required'});
|
|
99
|
+
}
|
|
100
|
+
const key = slugify(label);
|
|
101
|
+
if (!key) {
|
|
102
|
+
return reply.status(400).send({error: 'label must contain at least one alphanumeric character'});
|
|
103
|
+
}
|
|
104
|
+
const presets = getConfig('presets') || {};
|
|
105
|
+
if (presets[key]) {
|
|
106
|
+
return reply.status(409).send({error: 'A preset with this key already exists'});
|
|
107
|
+
}
|
|
108
|
+
presets[key] = {
|
|
109
|
+
key,
|
|
110
|
+
builtin: false,
|
|
111
|
+
...sanitisePreset({label, description, ...rest})
|
|
112
|
+
};
|
|
113
|
+
saveConfig('presets', presets);
|
|
114
|
+
return {success: true, key, preset: presets[key]};
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
fastify.put('/layouts/:key', canUpdate, async (request, reply) => {
|
|
118
|
+
const {key} = request.params;
|
|
119
|
+
const presets = getConfig('presets') || {};
|
|
120
|
+
if (!presets[key]) {
|
|
121
|
+
return reply.status(404).send({error: `Preset "${key}" not found`});
|
|
122
|
+
}
|
|
123
|
+
const {label, description, ...rest} = request.body || {};
|
|
124
|
+
if (!label || !String(label).trim()) {
|
|
125
|
+
return reply.status(400).send({error: 'label is required'});
|
|
126
|
+
}
|
|
127
|
+
presets[key] = {
|
|
128
|
+
...presets[key],
|
|
129
|
+
label: String(label).slice(0, 60).trim(),
|
|
130
|
+
description: String(description || '').slice(0, 200).trim(),
|
|
131
|
+
...sanitisePreset({label, description, ...rest}),
|
|
132
|
+
key,
|
|
133
|
+
builtin: presets[key].builtin // never overwrite builtin flag
|
|
134
|
+
};
|
|
135
|
+
saveConfig('presets', presets);
|
|
136
|
+
return {success: true, preset: presets[key]};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
fastify.delete('/layouts/:key', canUpdate, async (request, reply) => {
|
|
140
|
+
const {key} = request.params;
|
|
141
|
+
const presets = getConfig('presets') || {};
|
|
142
|
+
if (!presets[key]) {
|
|
143
|
+
return reply.status(404).send({error: `Preset "${key}" not found`});
|
|
144
|
+
}
|
|
145
|
+
if (presets[key].builtin) {
|
|
146
|
+
return reply.status(400).send({error: 'Built-in presets cannot be deleted'});
|
|
147
|
+
}
|
|
148
|
+
delete presets[key];
|
|
149
|
+
saveConfig('presets', presets);
|
|
150
|
+
return {success: true};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── Layout options ────────────────────────────────────────────────────────
|
|
154
|
+
fastify.get('/layouts/options', canRead, async () => {
|
|
155
|
+
return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
fastify.put('/layouts/options', canUpdate, async (request, reply) => {
|
|
159
|
+
const data = request.body;
|
|
160
|
+
if (!data || typeof data !== 'object') {
|
|
161
|
+
return reply.status(400).send({error: 'Invalid layout options'});
|
|
162
|
+
}
|
|
163
|
+
const site = getConfig('site');
|
|
164
|
+
const {spacerSize, spacerClass} = data;
|
|
165
|
+
site.layoutOptions = {
|
|
166
|
+
...site.layoutOptions,
|
|
167
|
+
...(spacerSize !== undefined ? {spacerSize: Math.max(0, Math.min(500, parseInt(spacerSize, 10) || 0))} : {}),
|
|
168
|
+
...(spacerClass !== undefined ? {spacerClass: String(spacerClass).replace(/[^a-zA-Z0-9\s_-]/g, '').trim()} : {})
|
|
169
|
+
};
|
|
170
|
+
saveConfig('site', site);
|
|
171
|
+
return {success: true};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications API
|
|
3
|
+
* POST /api/system/notifications - Manager push (requireManager)
|
|
4
|
+
* GET /api/system/notifications - List active notifications (admin/manager)
|
|
5
|
+
* GET /api/system/notifications/unread-count - Unread count for bell badge
|
|
6
|
+
* POST /api/system/notifications/:id/read - Mark read (idempotent)
|
|
7
|
+
* POST /api/system/notifications/:id/dismiss - Mark dismissed
|
|
8
|
+
* DELETE /api/system/notifications/:id - Hard delete (admin only)
|
|
9
|
+
*/
|
|
10
|
+
import { authenticate, requirePermission } from '../../middleware/auth.js';
|
|
11
|
+
import { requireManager } from '../../middleware/managerAuth.js';
|
|
12
|
+
import { getAdapter } from '../../services/adapterRegistry.js';
|
|
13
|
+
|
|
14
|
+
const SLUG = 'notifications';
|
|
15
|
+
|
|
16
|
+
export async function notificationsRoutes(fastify) {
|
|
17
|
+
const canRead = { preHandler: [authenticate, requirePermission('notifications', 'read')] };
|
|
18
|
+
const canDelete = { preHandler: [authenticate, requirePermission('notifications', 'delete')] };
|
|
19
|
+
|
|
20
|
+
// --- Manager inbound push ---
|
|
21
|
+
fastify.post('/system/notifications', { preHandler: [requireManager] }, async (request, reply) => {
|
|
22
|
+
const { title, body, severity = 'info', link, expiresAt } = request.body || {};
|
|
23
|
+
|
|
24
|
+
if (!title || typeof title !== 'string' || !title.trim()) {
|
|
25
|
+
return reply.code(400).send({ error: 'title is required.' });
|
|
26
|
+
}
|
|
27
|
+
if (!body || typeof body !== 'string' || !body.trim()) {
|
|
28
|
+
return reply.code(400).send({ error: 'body is required.' });
|
|
29
|
+
}
|
|
30
|
+
const VALID_SEVERITIES = ['info', 'success', 'warning', 'critical'];
|
|
31
|
+
if (!VALID_SEVERITIES.includes(severity)) {
|
|
32
|
+
return reply.code(400).send({ error: `severity must be one of: ${VALID_SEVERITIES.join(', ')}.` });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (link !== undefined && link !== null && link !== '') {
|
|
36
|
+
if (typeof link !== 'string' || !/^https?:\/\//i.test(link)) {
|
|
37
|
+
return reply.code(400).send({ error: 'link must be an absolute https:// or http:// URL.' });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (expiresAt !== undefined && expiresAt !== null && expiresAt !== '') {
|
|
42
|
+
if (typeof expiresAt !== 'string' || isNaN(Date.parse(expiresAt))) {
|
|
43
|
+
return reply.code(400).send({ error: 'expiresAt must be a valid ISO 8601 datetime string.' });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const adapter = await getAdapter(SLUG);
|
|
48
|
+
const entry = await adapter.insert(SLUG, {
|
|
49
|
+
title: title.trim(),
|
|
50
|
+
body: body.trim(),
|
|
51
|
+
severity,
|
|
52
|
+
source: 'manager',
|
|
53
|
+
link: link || null,
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
expiresAt: expiresAt || null,
|
|
56
|
+
readBy: [],
|
|
57
|
+
dismissedBy: []
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return reply.code(201).send(entry);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- List active notifications for current user ---
|
|
64
|
+
fastify.get('/system/notifications', canRead, async (request) => {
|
|
65
|
+
const userId = request.user.id;
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const adapter = await getAdapter(SLUG);
|
|
68
|
+
const all = await adapter.all(SLUG);
|
|
69
|
+
|
|
70
|
+
return all
|
|
71
|
+
.filter(entry => {
|
|
72
|
+
const d = entry.data;
|
|
73
|
+
// Exclude expired
|
|
74
|
+
if (d.expiresAt && d.expiresAt < now) return false;
|
|
75
|
+
// Exclude dismissed by this user
|
|
76
|
+
if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
|
|
77
|
+
return true;
|
|
78
|
+
})
|
|
79
|
+
.sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
|
|
80
|
+
.map(entry => ({
|
|
81
|
+
...entry,
|
|
82
|
+
unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)
|
|
83
|
+
}));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- Unread count (lightweight — for bell badge) ---
|
|
87
|
+
fastify.get('/system/notifications/unread-count', canRead, async (request) => {
|
|
88
|
+
const userId = request.user.id;
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const adapter = await getAdapter(SLUG);
|
|
91
|
+
const all = await adapter.all(SLUG);
|
|
92
|
+
|
|
93
|
+
const count = all.filter(entry => {
|
|
94
|
+
const d = entry.data;
|
|
95
|
+
if (d.expiresAt && d.expiresAt < now) return false;
|
|
96
|
+
if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
|
|
97
|
+
return !Array.isArray(d.readBy) || !d.readBy.includes(userId);
|
|
98
|
+
}).length;
|
|
99
|
+
|
|
100
|
+
return { count };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// --- Mark read (idempotent) ---
|
|
104
|
+
fastify.post('/system/notifications/:id/read', canRead, async (request, reply) => {
|
|
105
|
+
const { id } = request.params;
|
|
106
|
+
const userId = request.user.id;
|
|
107
|
+
const adapter = await getAdapter(SLUG);
|
|
108
|
+
const entry = await adapter.get(SLUG, id);
|
|
109
|
+
|
|
110
|
+
if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
|
|
111
|
+
|
|
112
|
+
const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
|
|
113
|
+
if (readBy.includes(userId)) return { ok: true }; // already read — idempotent
|
|
114
|
+
|
|
115
|
+
const updated = await adapter.update(SLUG, id, {
|
|
116
|
+
...entry.data,
|
|
117
|
+
readBy: [...readBy, userId]
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return { ok: true, entry: updated };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- Dismiss ---
|
|
124
|
+
fastify.post('/system/notifications/:id/dismiss', canRead, async (request, reply) => {
|
|
125
|
+
const { id } = request.params;
|
|
126
|
+
const userId = request.user.id;
|
|
127
|
+
const adapter = await getAdapter(SLUG);
|
|
128
|
+
const entry = await adapter.get(SLUG, id);
|
|
129
|
+
|
|
130
|
+
if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
|
|
131
|
+
|
|
132
|
+
const dismissedBy = Array.isArray(entry.data.dismissedBy) ? entry.data.dismissedBy : [];
|
|
133
|
+
const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
|
|
134
|
+
|
|
135
|
+
const updated = await adapter.update(SLUG, id, {
|
|
136
|
+
...entry.data,
|
|
137
|
+
dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
|
|
138
|
+
readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return { ok: true, entry: updated };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- Hard delete (admin only) ---
|
|
145
|
+
fastify.delete('/system/notifications/:id', canDelete, async (request, reply) => {
|
|
146
|
+
const { id } = request.params;
|
|
147
|
+
const adapter = await getAdapter(SLUG);
|
|
148
|
+
const entry = await adapter.get(SLUG, id);
|
|
149
|
+
|
|
150
|
+
if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
|
|
151
|
+
|
|
152
|
+
await adapter.remove(SLUG, id);
|
|
153
|
+
return { ok: true };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Marketplace API
|
|
3
|
+
* GET /api/plugins/marketplace — fetch catalogue from manager (requires MANAGER_URL)
|
|
4
|
+
* POST /api/plugins/marketplace/install — download + install a plugin bundle
|
|
5
|
+
* DELETE /api/plugins/marketplace/:slug — uninstall an installed plugin
|
|
6
|
+
*
|
|
7
|
+
* All routes require authentication + admin role.
|
|
8
|
+
*/
|
|
9
|
+
import {authenticate, requireAdmin} from '../../middleware/auth.js';
|
|
10
|
+
import {fetchCatalogue, fetchPluginBundle} from '../../services/managerClient.js';
|
|
11
|
+
import {installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
|
|
12
|
+
|
|
13
|
+
export async function pluginMarketplaceRoutes(fastify) {
|
|
14
|
+
const adminOnly = { preHandler: [authenticate, requireAdmin] };
|
|
15
|
+
|
|
16
|
+
// GET /plugins/marketplace
|
|
17
|
+
fastify.get('/plugins/marketplace', adminOnly, async (_request, reply) => {
|
|
18
|
+
if (!process.env.MANAGER_URL) {
|
|
19
|
+
return reply.send({ available: false, catalogue: [] });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const licenceToken = process.env.LICENCE_TOKEN;
|
|
23
|
+
const catalogue = await fetchCatalogue(licenceToken);
|
|
24
|
+
return reply.send({ available: true, catalogue });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// POST /plugins/marketplace/install
|
|
28
|
+
fastify.post('/plugins/marketplace/install', adminOnly, async (request, reply) => {
|
|
29
|
+
const { slug, version } = request.body || {};
|
|
30
|
+
|
|
31
|
+
if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
|
|
32
|
+
return reply.code(400).send({ error: 'Invalid plugin slug.' });
|
|
33
|
+
}
|
|
34
|
+
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
35
|
+
return reply.code(400).send({ error: 'Invalid version format. Expected semver (e.g. 1.0.0).' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const licenceToken = process.env.LICENCE_TOKEN;
|
|
39
|
+
const bundle = await fetchPluginBundle(slug, version, licenceToken);
|
|
40
|
+
|
|
41
|
+
if (!bundle) {
|
|
42
|
+
return reply.code(503).send({ error: 'Manager unavailable' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await installPlugin(slug, bundle);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
fastify.log.error({ err }, '[marketplace] install failed');
|
|
49
|
+
// Known safe error messages from pluginInstaller (for admin clarity)
|
|
50
|
+
const safeMessages = ['Plugin already installed', 'Plugin signature invalid', 'Unknown public key', 'Invalid plugin slug'];
|
|
51
|
+
const safe = safeMessages.some(m => err.message.startsWith(m));
|
|
52
|
+
return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return reply.send({ success: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// DELETE /plugins/marketplace/:slug
|
|
59
|
+
fastify.delete('/plugins/marketplace/:slug', adminOnly, async (request, reply) => {
|
|
60
|
+
const { slug } = request.params;
|
|
61
|
+
|
|
62
|
+
if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
|
|
63
|
+
return reply.code(400).send({ error: 'Invalid plugin slug.' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await uninstallPlugin(slug);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
fastify.log.error({ err }, '[marketplace] uninstall failed');
|
|
70
|
+
return reply.code(400).send({ error: 'Uninstall failed.' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return reply.send({ success: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -54,7 +54,7 @@ export async function usersRoutes(fastify) {
|
|
|
54
54
|
return reply.status(400).send({ error: 'Password must be at least 8 characters' });
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const targetRole = role || '
|
|
57
|
+
const targetRole = role || 'user';
|
|
58
58
|
if (!canManageUser(request.user.role, targetRole)) {
|
|
59
59
|
return reply.code(403).send({ error: 'You cannot create a user with that role' });
|
|
60
60
|
}
|
|
@@ -125,7 +125,7 @@ export async function viewsRoutes(fastify) {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
const user = request.user;
|
|
128
|
-
const allowedRoles = view.access?.roles || ['admin'];
|
|
128
|
+
const allowedRoles = view.access?.roles || ['admin', 'super-admin'];
|
|
129
129
|
const userLevel = getRoleLevel(user?.role);
|
|
130
130
|
const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
|
|
131
131
|
|
package/server/routes/public.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {getPage} from '../services/content.js';
|
|
8
8
|
import {renderPage} from '../services/renderer.js';
|
|
9
|
-
import {
|
|
9
|
+
import {checkVisibility} from '../middleware/auth.js';
|
|
10
10
|
import {hooks} from '../services/hooks.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -69,15 +69,10 @@ export async function publicRoutes(fastify) {
|
|
|
69
69
|
let userRole = null;
|
|
70
70
|
try {
|
|
71
71
|
const decoded = await request.jwtVerify();
|
|
72
|
-
userRole = decoded.role;
|
|
73
|
-
} catch { /* no token — treat as unauthenticated */
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const userLevel = getRoleLevel(userRole);
|
|
77
|
-
const visibilityLevel = getRoleLevel(page.visibility);
|
|
78
|
-
const requiredLevel = visibilityLevel === Infinity ? 0 : visibilityLevel;
|
|
72
|
+
if (decoded.type === 'access') userRole = decoded.role;
|
|
73
|
+
} catch { /* no token — treat as unauthenticated */ }
|
|
79
74
|
|
|
80
|
-
if (
|
|
75
|
+
if (!checkVisibility(userRole, page.visibility)) {
|
|
81
76
|
reply.status(403);
|
|
82
77
|
return reply.type('text/html').send(accessDeniedHtml(urlPath));
|
|
83
78
|
}
|
package/server/server.js
CHANGED
|
@@ -17,7 +17,7 @@ import fs from 'fs/promises';
|
|
|
17
17
|
import {fileURLToPath} from 'url';
|
|
18
18
|
import {createRequire} from 'module';
|
|
19
19
|
import {config, getConfig} from './config.js';
|
|
20
|
-
import {registerPlugins} from './services/plugins.js';
|
|
20
|
+
import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/plugins.js';
|
|
21
21
|
import {load as loadRoles, seed as seedRoles} from './services/roles.js';
|
|
22
22
|
import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
|
|
23
23
|
import {seedAll as seedPresetCollections} from './services/presetCollections.js';
|
|
@@ -44,6 +44,13 @@ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
|
|
|
44
44
|
process.exit(1);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// MANAGER_SECRET is optional — only needed when domma-cms-manager pushes notifications.
|
|
48
|
+
// Warn if set but insecure; silently accept if absent (manager push is disabled).
|
|
49
|
+
const MANAGER_SECRET = process.env.MANAGER_SECRET;
|
|
50
|
+
if (MANAGER_SECRET && MANAGER_SECRET.length < 32) {
|
|
51
|
+
console.warn(' WARNING: MANAGER_SECRET is set but too short (minimum 32 characters). Manager notifications disabled until fixed.');
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
const app = Fastify({
|
|
48
55
|
logger: {level: process.env.NODE_ENV === 'development' ? 'info' : 'warn'},
|
|
49
56
|
// When running behind a reverse proxy (e.g. domma-cms-manager), trust the
|
|
@@ -227,7 +234,8 @@ const { layoutsRoutes } = await import('./routes/api/layouts.js');
|
|
|
227
234
|
const { navigationRoutes } = await import('./routes/api/navigation.js');
|
|
228
235
|
const { mediaRoutes } = await import('./routes/api/media.js');
|
|
229
236
|
const { usersRoutes } = await import('./routes/api/users.js');
|
|
230
|
-
const { pluginsRoutes }
|
|
237
|
+
const { pluginsRoutes } = await import('./routes/api/plugins.js');
|
|
238
|
+
const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
|
|
231
239
|
const { collectionsRoutes } = await import('./routes/api/collections.js');
|
|
232
240
|
const { formsRoutes } = await import('./routes/api/forms.js');
|
|
233
241
|
const { viewsRoutes } = await import('./routes/api/views.js');
|
|
@@ -242,7 +250,8 @@ await app.register(layoutsRoutes, { prefix: '/api' });
|
|
|
242
250
|
await app.register(navigationRoutes, { prefix: '/api' });
|
|
243
251
|
await app.register(mediaRoutes, { prefix: '/api' });
|
|
244
252
|
await app.register(usersRoutes, { prefix: '/api' });
|
|
245
|
-
await app.register(pluginsRoutes,
|
|
253
|
+
await app.register(pluginsRoutes, { prefix: '/api' });
|
|
254
|
+
await app.register(pluginMarketplaceRoutes, { prefix: '/api' });
|
|
246
255
|
await app.register(collectionsRoutes, { prefix: '/api' });
|
|
247
256
|
await app.register(formsRoutes, { prefix: '/api' });
|
|
248
257
|
await app.register(viewsRoutes, { prefix: '/api' });
|
|
@@ -257,6 +266,26 @@ await app.register(effectsRoutes, {prefix: '/api'});
|
|
|
257
266
|
|
|
258
267
|
await registerPlugins(app);
|
|
259
268
|
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Public Plugin Routes (root-level, before catch-all)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
const { authenticate: _authenticate, requireAdmin: _requireAdmin, requireVisibility: _requireVisibility } = await import('./middleware/auth.js');
|
|
274
|
+
for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
|
|
275
|
+
if (!plugin.enabled || !plugin.publicEntry) continue;
|
|
276
|
+
try {
|
|
277
|
+
const { default: publicPlugin } = await import(plugin.publicEntry);
|
|
278
|
+
const settings = await getPluginSettings(name);
|
|
279
|
+
await app.register(publicPlugin, {
|
|
280
|
+
settings,
|
|
281
|
+
auth: { authenticate: _authenticate, requireAdmin: _requireAdmin, requireVisibility: _requireVisibility },
|
|
282
|
+
config: getConfig()
|
|
283
|
+
});
|
|
284
|
+
} catch (err) {
|
|
285
|
+
app.log.error(`[plugins] Failed to register public plugin ${name}: ${err.message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
260
289
|
// ---------------------------------------------------------------------------
|
|
261
290
|
// Public Site (catch-all — must be last)
|
|
262
291
|
// ---------------------------------------------------------------------------
|