domma-cms 0.10.0 → 0.13.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/documentation.html +611 -2
- 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/markdown.js +52 -14
- 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
|
@@ -125,6 +125,54 @@ export function canManageUser(actorRole, targetRole) {
|
|
|
125
125
|
return getRoleLevel(actorRole) < getRoleLevel(targetRole);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Check whether a user role satisfies a visibility requirement.
|
|
130
|
+
* Used by both requireVisibility() and the public page renderer.
|
|
131
|
+
*
|
|
132
|
+
* @param {string|null} userRole - The visitor's role, or null if unauthenticated
|
|
133
|
+
* @param {string} visibility - Required visibility ('public', 'private', or a role name)
|
|
134
|
+
* @returns {boolean} true if access is granted
|
|
135
|
+
*/
|
|
136
|
+
export function checkVisibility(userRole, visibility) {
|
|
137
|
+
if (!visibility || visibility === 'public') return true;
|
|
138
|
+
if (!userRole) return false; // unauthenticated, non-public page
|
|
139
|
+
const userLevel = getRoleLevel(userRole);
|
|
140
|
+
const requiredLevel = getRoleLevel(visibility);
|
|
141
|
+
const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
|
|
142
|
+
return userLevel <= threshold;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fastify preHandler factory — gates a route by visibility level.
|
|
147
|
+
* Works identically to the content-page visibility system.
|
|
148
|
+
* Returns a no-op for 'public' so it is safe to apply unconditionally.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} visibility - 'public' | 'private' | any role name
|
|
151
|
+
* @returns {Function} Fastify preHandler
|
|
152
|
+
*/
|
|
153
|
+
export function requireVisibility(visibility) {
|
|
154
|
+
if (!visibility || visibility === 'public') {
|
|
155
|
+
return (_request, _reply, done) => { if (done) done(); };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return async (request, reply) => {
|
|
159
|
+
let userRole = null;
|
|
160
|
+
try {
|
|
161
|
+
const decoded = await request.jwtVerify();
|
|
162
|
+
if (decoded.type === 'access') userRole = decoded.role;
|
|
163
|
+
} catch { /* unauthenticated */ }
|
|
164
|
+
|
|
165
|
+
if (!checkVisibility(userRole, visibility)) {
|
|
166
|
+
const code = userRole ? 403 : 401;
|
|
167
|
+
return reply.code(code).send({
|
|
168
|
+
statusCode: code,
|
|
169
|
+
error: code === 403 ? 'Forbidden' : 'Unauthorised',
|
|
170
|
+
message: code === 403 ? 'Insufficient role for this resource' : 'Authentication required'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
128
176
|
/**
|
|
129
177
|
* Return role names ordered from most to least privileged.
|
|
130
178
|
* Computed from the roles cache.
|
|
@@ -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
|
}
|