domma-cms 0.3.0 → 0.5.2
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/README.md +3 -3
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +2313 -0
- package/admin/dist/domma/domma-tools.min.js +10 -0
- package/admin/index.html +4 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +8 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +18 -10
- package/admin/js/templates/action-editor.html +171 -0
- package/admin/js/templates/actions-list.html +19 -0
- package/admin/js/templates/api-reference.html +1411 -0
- package/admin/js/templates/block-editor.html +158 -0
- package/admin/js/templates/blocks.html +8 -0
- package/admin/js/templates/collection-editor.html +47 -0
- package/admin/js/templates/collection-entries.html +3 -0
- package/admin/js/templates/collections.html +51 -4
- package/admin/js/templates/documentation.html +258 -0
- package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
- package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
- package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
- package/admin/js/templates/login.html +29 -4
- package/admin/js/templates/my-profile.html +17 -0
- package/admin/js/templates/page-editor.html +39 -0
- package/admin/js/templates/pages.html +6 -1
- package/admin/js/templates/pro-docs.html +259 -0
- package/admin/js/templates/role-editor.html +59 -0
- package/admin/js/templates/roles.html +10 -0
- package/admin/js/templates/settings.html +167 -23
- package/admin/js/templates/tutorials.html +81 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/templates/users.html +3 -26
- package/admin/js/templates/view-editor.html +201 -0
- package/admin/js/templates/view-preview.html +51 -0
- package/admin/js/templates/views-list.html +19 -0
- package/admin/js/views/action-editor.js +1 -0
- package/admin/js/views/actions-list.js +1 -0
- package/admin/js/views/api-reference.js +1 -0
- package/admin/js/views/block-editor.js +8 -0
- package/admin/js/views/blocks.js +4 -0
- package/admin/js/views/collection-editor.js +3 -3
- package/admin/js/views/collection-entries.js +1 -1
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +8 -0
- package/admin/js/views/form-submissions.js +1 -0
- package/admin/js/views/forms.js +1 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/media.js +1 -1
- package/admin/js/views/my-profile.js +1 -0
- package/admin/js/views/page-editor.js +34 -15
- package/admin/js/views/pages.js +5 -5
- package/admin/js/views/plugins.js +10 -10
- package/admin/js/views/pro-docs.js +1 -0
- package/admin/js/views/role-editor.js +1 -0
- package/admin/js/views/roles.js +4 -0
- package/admin/js/views/settings.js +3 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +4 -7
- package/admin/js/views/view-editor.js +1 -0
- package/admin/js/views/view-preview.js +1 -0
- package/admin/js/views/views-list.js +1 -0
- package/bin/cli.js +1 -1
- package/config/auth.json +1 -0
- package/config/connections.json.bak +9 -0
- package/config/connections.json.example +9 -0
- package/config/navigation.json +5 -15
- package/config/plugins.json +19 -29
- package/config/server.json +6 -6
- package/config/site.json +16 -6
- package/package.json +25 -10
- package/plugins/example-analytics/stats.json +17 -12
- package/plugins/form-builder/data/forms/contacts.json +62 -62
- package/plugins/form-builder/data/forms/enquiries.json +103 -0
- package/plugins/form-builder/data/forms/feedback.json +17 -16
- package/plugins/form-builder/data/forms/notes.json +79 -0
- package/plugins/form-builder/data/forms/to-do.json +100 -0
- package/plugins/form-builder/data/submissions/contacts.json +1 -26
- package/plugins/form-builder/data/submissions/notes.json +1 -0
- package/plugins/form-builder/data/submissions/to-do.json +1 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/plugin.js +233 -0
- package/plugins/theme-roller/plugin.json +31 -0
- package/plugins/theme-roller/public/active-theme.css +0 -0
- package/plugins/theme-roller/public/inject-head-late.html +1 -0
- package/public/css/forms.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/forms.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +194 -129
- package/scripts/pro.js +254 -0
- package/scripts/reset.js +33 -8
- package/scripts/seed.js +677 -128
- package/scripts/setup.js +1 -0
- package/server/middleware/auth.js +136 -120
- package/server/routes/api/actions.js +200 -0
- package/server/routes/api/auth.js +292 -146
- package/server/routes/api/blocks.js +84 -0
- package/server/routes/api/collections.js +79 -27
- package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
- package/server/routes/api/layouts.js +49 -39
- package/server/routes/api/media.js +118 -92
- package/server/routes/api/navigation.js +40 -36
- package/server/routes/api/pages.js +132 -118
- package/server/routes/api/plugins.js +6 -3
- package/server/routes/api/settings.js +104 -88
- package/server/routes/api/users.js +27 -19
- package/server/routes/api/views.js +148 -0
- package/server/routes/public.js +124 -108
- package/server/server.js +269 -181
- package/server/services/actions.js +387 -0
- package/server/services/adapterRegistry.js +98 -0
- package/server/services/adapters/FileAdapter.js +192 -0
- package/server/services/adapters/MongoAdapter.js +220 -0
- package/server/services/blocks.js +162 -0
- package/server/services/collections.js +74 -86
- package/server/services/connectionManager.js +102 -0
- package/server/services/content.js +312 -307
- package/server/services/email.js +126 -0
- package/server/services/forms.js +173 -0
- package/server/services/markdown.js +1378 -747
- package/server/services/permissionRegistry.js +173 -0
- package/server/services/presetCollections.js +251 -0
- package/server/services/renderer.js +98 -2
- package/server/services/roles.js +227 -0
- package/server/services/rowAccess.js +104 -0
- package/server/services/userProfiles.js +199 -0
- package/server/services/users.js +281 -212
- package/server/services/views.js +280 -0
- package/server/templates/page.html +124 -113
- package/plugins/form-builder/admin/templates/form-settings.html +0 -29
- package/plugins/form-builder/admin/views/form-editor.js +0 -1444
- package/plugins/form-builder/admin/views/form-settings.js +0 -38
- package/plugins/form-builder/admin/views/form-submissions.js +0 -295
- package/plugins/form-builder/admin/views/forms-list.js +0 -164
- package/plugins/form-builder/config.js +0 -9
- package/plugins/form-builder/data/forms/consent.json +0 -104
- package/plugins/form-builder/data/forms/contact-details.json +0 -99
- package/plugins/form-builder/data/submissions/consent.json +0 -13
- package/plugins/form-builder/plugin.json +0 -52
- package/plugins/form-builder/public/inject-body.html +0 -352
- package/plugins/form-builder/public/inject-head.html +0 -58
- package/plugins/form-builder/public/package.json +0 -1
- package/scripts/copy-domma.js +0 -48
- package/server/services/userTypes.js +0 -167
- /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
- /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
|
@@ -1,39 +1,49 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layouts (Presets) API
|
|
3
|
-
* GET /api/layouts - get all layout presets
|
|
4
|
-
* PUT /api/layouts - save layout presets
|
|
5
|
-
*/
|
|
6
|
-
import {getConfig, saveConfig} from '../../config.js';
|
|
7
|
-
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
|
-
|
|
9
|
-
export async function layoutsRoutes(fastify) {
|
|
10
|
-
const
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Layouts (Presets) API
|
|
3
|
+
* GET /api/layouts - get all layout presets
|
|
4
|
+
* PUT /api/layouts - save layout presets
|
|
5
|
+
*/
|
|
6
|
+
import {getConfig, saveConfig} from '../../config.js';
|
|
7
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
|
+
|
|
9
|
+
export async function layoutsRoutes(fastify) {
|
|
10
|
+
const canRead = {preHandler: [authenticate, requirePermission('layouts', 'read')]};
|
|
11
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('layouts', 'update')]};
|
|
12
|
+
|
|
13
|
+
fastify.get('/layouts', canRead, async () => {
|
|
14
|
+
return getConfig('presets');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
fastify.put('/layouts', canUpdate, async (request, reply) => {
|
|
18
|
+
const data = request.body;
|
|
19
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
20
|
+
return reply.status(400).send({ error: 'Invalid presets data: expected an object' });
|
|
21
|
+
}
|
|
22
|
+
// Each preset value must be an object with at least a name field
|
|
23
|
+
for (const [key, preset] of Object.entries(data)) {
|
|
24
|
+
if (!preset || typeof preset !== 'object' || Array.isArray(preset)) {
|
|
25
|
+
return reply.status(400).send({ error: `Invalid preset "${key}": expected an object` });
|
|
26
|
+
}
|
|
27
|
+
if (typeof preset.name !== 'string' || !preset.name.trim()) {
|
|
28
|
+
return reply.status(400).send({ error: `Invalid preset "${key}": missing or empty "name" field` });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
saveConfig('presets', data);
|
|
32
|
+
return { success: true };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
fastify.get('/layouts/options', canRead, async () => {
|
|
36
|
+
return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
fastify.put('/layouts/options', canUpdate, async (request, reply) => {
|
|
40
|
+
const data = request.body;
|
|
41
|
+
if (!data || typeof data !== 'object') {
|
|
42
|
+
return reply.status(400).send({error: 'Invalid layout options'});
|
|
43
|
+
}
|
|
44
|
+
const site = getConfig('site');
|
|
45
|
+
site.layoutOptions = {...site.layoutOptions, ...data};
|
|
46
|
+
saveConfig('site', site);
|
|
47
|
+
return {success: true};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -1,92 +1,118 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Media API
|
|
3
|
-
* GET /api/media - list media files
|
|
4
|
-
* POST /api/media - upload a file
|
|
5
|
-
* DELETE /api/media/:name - delete a file
|
|
6
|
-
*/
|
|
7
|
-
import path from 'path';
|
|
8
|
-
import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
|
|
9
|
-
import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
|
|
10
|
-
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Media API
|
|
3
|
+
* GET /api/media - list media files
|
|
4
|
+
* POST /api/media - upload a file
|
|
5
|
+
* DELETE /api/media/:name - delete a file
|
|
6
|
+
*/
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
|
|
9
|
+
import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
|
|
10
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
11
|
+
|
|
12
|
+
const ALLOWED_MIME_TYPES = new Set([
|
|
13
|
+
// Images
|
|
14
|
+
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
|
15
|
+
'image/svg+xml', 'image/x-icon', 'image/bmp', 'image/tiff',
|
|
16
|
+
// Documents
|
|
17
|
+
'application/pdf', 'text/plain', 'text/csv',
|
|
18
|
+
'application/msword',
|
|
19
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
20
|
+
'application/vnd.ms-excel',
|
|
21
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
22
|
+
// Video
|
|
23
|
+
'video/mp4', 'video/webm', 'video/ogg',
|
|
24
|
+
// Audio
|
|
25
|
+
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', 'audio/webm',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Safe filename: strip path traversal and restrict to alphanumeric + safe chars
|
|
29
|
+
function sanitiseFilename(name) {
|
|
30
|
+
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function mediaRoutes(fastify) {
|
|
34
|
+
const canRead = {preHandler: [authenticate, requirePermission('media', 'read')]};
|
|
35
|
+
const canCreate = {preHandler: [authenticate, requirePermission('media', 'create')]};
|
|
36
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('media', 'update')]};
|
|
37
|
+
const canDelete = {preHandler: [authenticate, requirePermission('media', 'delete')]};
|
|
38
|
+
|
|
39
|
+
fastify.get('/media', canRead, async () => {
|
|
40
|
+
return listMedia();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
fastify.post('/media', canCreate, async (request, reply) => {
|
|
44
|
+
const results = [];
|
|
45
|
+
for await (const data of request.files()) {
|
|
46
|
+
const filename = sanitiseFilename(data.filename);
|
|
47
|
+
if (!ALLOWED_MIME_TYPES.has(data.mimetype)) {
|
|
48
|
+
// Drain the stream to avoid leaving it open
|
|
49
|
+
for await (const _ of data.file) { /* drain */ }
|
|
50
|
+
return reply.code(400).send({
|
|
51
|
+
error: `File type '${data.mimetype}' is not allowed. Allowed types: images, documents, audio, video.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const chunks = [];
|
|
55
|
+
for await (const chunk of data.file) {
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
}
|
|
58
|
+
results.push(await saveMedia(filename, Buffer.concat(chunks)));
|
|
59
|
+
}
|
|
60
|
+
if (!results.length) return reply.status(400).send({error: 'No file uploaded'});
|
|
61
|
+
return reply.status(201).send(results.length === 1 ? results[0] : results);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
fastify.patch('/media/:name', canUpdate, async (request, reply) => {
|
|
65
|
+
const oldName = sanitiseFilename(request.params.name);
|
|
66
|
+
const newName = sanitiseFilename(request.body?.newName ?? '');
|
|
67
|
+
if (!newName) return reply.status(400).send({error: 'newName is required.'});
|
|
68
|
+
if (oldName === newName) return reply.status(400).send({error: 'New name is the same as the current name.'});
|
|
69
|
+
try {
|
|
70
|
+
return await renameMedia(oldName, newName);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return reply.status(409).send({error: err.message});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
fastify.delete('/media/:name', canDelete, async (request, reply) => {
|
|
77
|
+
const name = sanitiseFilename(request.params.name);
|
|
78
|
+
await deleteMedia(name);
|
|
79
|
+
return { success: true };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
fastify.get('/media/:name/info', canRead, async (request, reply) => {
|
|
83
|
+
const name = sanitiseFilename(request.params.name);
|
|
84
|
+
if (!isEditableImage(name)) {
|
|
85
|
+
return reply.status(400).send({error: 'Not an editable image format'});
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return await getImageInfo(name);
|
|
89
|
+
} catch {
|
|
90
|
+
return reply.status(404).send({error: 'File not found'});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
fastify.post('/media/:name/transform', canUpdate, async (request, reply) => {
|
|
95
|
+
const name = sanitiseFilename(request.params.name);
|
|
96
|
+
if (!isEditableImage(name)) {
|
|
97
|
+
return reply.status(400).send({error: 'Not an editable image format'});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const {operations = {}, saveAs} = request.body ?? {};
|
|
101
|
+
|
|
102
|
+
// Sanitise fields that reference filesystem paths
|
|
103
|
+
if (operations.watermark?.image) {
|
|
104
|
+
operations.watermark.image = sanitiseFilename(operations.watermark.image);
|
|
105
|
+
}
|
|
106
|
+
if (operations._deleteOriginal) {
|
|
107
|
+
operations._deleteOriginal = sanitiseFilename(operations._deleteOriginal);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const outputFilename = saveAs ? sanitiseFilename(saveAs) : null;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
return await transformImage(name, operations, outputFilename);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return reply.status(500).send({error: err.message});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -1,36 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Navigation API
|
|
3
|
-
* GET /api/navigation - get navigation config
|
|
4
|
-
* PUT /api/navigation - save navigation config
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
export async function navigationRoutes(fastify) {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (Array.isArray(data.items)) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
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
|
+
|
|
9
|
+
export async function navigationRoutes(fastify) {
|
|
10
|
+
const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
|
|
11
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
|
|
12
|
+
|
|
13
|
+
fastify.get('/navigation', canRead, async () => {
|
|
14
|
+
return getConfig('navigation');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
fastify.put('/navigation', canUpdate, async (request, reply) => {
|
|
18
|
+
const data = request.body;
|
|
19
|
+
if (!data || typeof data !== 'object') {
|
|
20
|
+
return reply.status(400).send({ error: 'Invalid navigation data' });
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(data.items) && !Array.isArray(data)) {
|
|
23
|
+
return reply.status(400).send({ error: 'Navigation must be an array of items' });
|
|
24
|
+
}
|
|
25
|
+
// Normalise child key: Domma navbar expects `items`, not `children`
|
|
26
|
+
if (Array.isArray(data.items)) {
|
|
27
|
+
data.items = data.items.map(item => {
|
|
28
|
+
const children = item.items || item.children;
|
|
29
|
+
if (children?.length) {
|
|
30
|
+
const { children: _c, ...rest } = item;
|
|
31
|
+
return { ...rest, items: children };
|
|
32
|
+
}
|
|
33
|
+
const { children: _c, ...rest } = item;
|
|
34
|
+
return rest;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
saveConfig('navigation', data);
|
|
38
|
+
return { success: true };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1,118 +1,132 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pages API
|
|
3
|
-
* GET /api/pages - list all pages
|
|
4
|
-
* GET /api/pages/* - get single page by URL path
|
|
5
|
-
* POST /api/pages - create page
|
|
6
|
-
* PUT /api/pages/* - update page
|
|
7
|
-
* DELETE /api/pages/* - delete page
|
|
8
|
-
*/
|
|
9
|
-
import {createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
|
|
10
|
-
import {parseMarkdown} from '../../services/markdown.js';
|
|
11
|
-
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
12
|
-
import {getConfig, saveConfig} from '../../config.js';
|
|
13
|
-
|
|
14
|
-
export async function pagesRoutes(fastify) {
|
|
15
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Pages API
|
|
3
|
+
* GET /api/pages - list all pages
|
|
4
|
+
* GET /api/pages/* - get single page by URL path
|
|
5
|
+
* POST /api/pages - create page
|
|
6
|
+
* PUT /api/pages/* - update page
|
|
7
|
+
* DELETE /api/pages/* - delete page
|
|
8
|
+
*/
|
|
9
|
+
import {createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
|
|
10
|
+
import {parseMarkdown} from '../../services/markdown.js';
|
|
11
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
12
|
+
import {getConfig, saveConfig} from '../../config.js';
|
|
13
|
+
|
|
14
|
+
export async function pagesRoutes(fastify) {
|
|
15
|
+
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
16
|
+
const canCreate = {preHandler: [authenticate, requirePermission('pages', 'create')]};
|
|
17
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
18
|
+
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
|
|
19
|
+
|
|
20
|
+
// Render markdown preview (shortcodes + sanitize, no frontmatter)
|
|
21
|
+
fastify.post('/pages/preview', canRead, async (request, reply) => {
|
|
22
|
+
const {markdown} = request.body;
|
|
23
|
+
if (typeof markdown !== 'string') return reply.status(400).send({error: 'markdown must be a string'});
|
|
24
|
+
const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}`);
|
|
25
|
+
return {html};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Aggregate unique tags from all pages — must be registered before the wildcard /pages/* route
|
|
29
|
+
fastify.get('/pages/tags', canRead, async (request, reply) => {
|
|
30
|
+
const pages = await listPages();
|
|
31
|
+
const tagSet = new Set();
|
|
32
|
+
pages.forEach(p => {
|
|
33
|
+
if (Array.isArray(p.tags)) p.tags.forEach(t => { if (t) tagSet.add(t); });
|
|
34
|
+
});
|
|
35
|
+
return { tags: [...tagSet].sort() };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// List all pages
|
|
39
|
+
fastify.get('/pages', canRead, async (request, reply) => {
|
|
40
|
+
const pages = await listPages();
|
|
41
|
+
return pages.map(p => ({
|
|
42
|
+
urlPath: p.urlPath,
|
|
43
|
+
title: p.title,
|
|
44
|
+
slug: p.slug,
|
|
45
|
+
status: p.status,
|
|
46
|
+
layout: p.layout,
|
|
47
|
+
showInNav: p.showInNav,
|
|
48
|
+
sortOrder: p.sortOrder,
|
|
49
|
+
category: p.category || null,
|
|
50
|
+
visibility: p.visibility || 'public',
|
|
51
|
+
tags: p.tags || [],
|
|
52
|
+
updatedAt: p.updatedAt,
|
|
53
|
+
createdAt: p.createdAt
|
|
54
|
+
}));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Get single page
|
|
58
|
+
fastify.get('/pages/*', canRead, async (request, reply) => {
|
|
59
|
+
const urlPath = '/' + request.params['*'];
|
|
60
|
+
const page = await getPage(urlPath);
|
|
61
|
+
if (!page) return reply.status(404).send({ error: 'Page not found' });
|
|
62
|
+
return page;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Create page
|
|
66
|
+
fastify.post('/pages', canCreate, async (request, reply) => {
|
|
67
|
+
const { urlPath, frontmatter, body } = request.body;
|
|
68
|
+
if (!urlPath) return reply.status(400).send({ error: 'urlPath is required' });
|
|
69
|
+
|
|
70
|
+
const existing = await getPage(urlPath);
|
|
71
|
+
if (existing) return reply.status(409).send({ error: 'Page already exists at that path' });
|
|
72
|
+
|
|
73
|
+
const page = await createPage(urlPath, frontmatter || {}, body || '');
|
|
74
|
+
return reply.status(201).send(page);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Update page (optionally rename to a new URL path)
|
|
78
|
+
fastify.put('/pages/*', canUpdate, async (request, reply) => {
|
|
79
|
+
const urlPath = '/' + request.params['*'];
|
|
80
|
+
const { frontmatter, body, newUrlPath } = request.body;
|
|
81
|
+
|
|
82
|
+
const existing = await getPage(urlPath);
|
|
83
|
+
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
84
|
+
|
|
85
|
+
if (newUrlPath && newUrlPath !== urlPath) {
|
|
86
|
+
const conflict = await getPage(newUrlPath);
|
|
87
|
+
if (conflict) return reply.status(409).send({ error: 'A page already exists at that path' });
|
|
88
|
+
|
|
89
|
+
await renamePage(urlPath, newUrlPath);
|
|
90
|
+
await rewriteNavLinks(urlPath, newUrlPath);
|
|
91
|
+
|
|
92
|
+
const page = await updatePage(newUrlPath, frontmatter || {}, body);
|
|
93
|
+
return page;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const page = await updatePage(urlPath, frontmatter || {}, body);
|
|
97
|
+
return page;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Delete page
|
|
101
|
+
fastify.delete('/pages/*', canDelete, async (request, reply) => {
|
|
102
|
+
const urlPath = '/' + request.params['*'];
|
|
103
|
+
const existing = await getPage(urlPath);
|
|
104
|
+
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
105
|
+
|
|
106
|
+
await deletePage(urlPath);
|
|
107
|
+
return { success: true };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Rewrite any navigation item URLs that match oldUrlPath to newUrlPath,
|
|
113
|
+
* then persist the updated navigation config.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} oldUrlPath
|
|
116
|
+
* @param {string} newUrlPath
|
|
117
|
+
* @returns {Promise<void>}
|
|
118
|
+
*/
|
|
119
|
+
async function rewriteNavLinks(oldUrlPath, newUrlPath) {
|
|
120
|
+
const nav = getConfig('navigation');
|
|
121
|
+
if (!nav?.items?.length) return;
|
|
122
|
+
|
|
123
|
+
let changed = false;
|
|
124
|
+
for (const item of nav.items) {
|
|
125
|
+
if (item.url === oldUrlPath) { item.url = newUrlPath; changed = true; }
|
|
126
|
+
for (const child of item.children || []) {
|
|
127
|
+
if (child.url === oldUrlPath) { child.url = newUrlPath; changed = true; }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (changed) await saveConfig('navigation', nav);
|
|
132
|
+
}
|