domma-cms 0.2.1 → 0.5.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.
Files changed (166) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1200
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -242
  7. package/admin/js/app.js +9 -279
  8. package/admin/js/config/sidebar-config.js +1 -115
  9. package/admin/js/lib/card.js +1 -63
  10. package/admin/js/lib/image-editor.js +1 -869
  11. package/admin/js/lib/markdown-toolbar.js +54 -421
  12. package/admin/js/templates/action-editor.html +171 -0
  13. package/admin/js/templates/actions-list.html +19 -0
  14. package/admin/js/templates/api-reference.html +1411 -0
  15. package/admin/js/templates/block-editor.html +158 -0
  16. package/admin/js/templates/blocks.html +8 -0
  17. package/admin/js/templates/collection-editor.html +47 -0
  18. package/admin/js/templates/collection-entries.html +3 -0
  19. package/admin/js/templates/collections.html +51 -4
  20. package/admin/js/templates/documentation.html +258 -0
  21. package/admin/js/templates/form-editor.html +238 -0
  22. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  23. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  24. package/admin/js/templates/layouts.html +44 -7
  25. package/admin/js/templates/login.html +29 -4
  26. package/admin/js/templates/my-profile.html +17 -0
  27. package/admin/js/templates/page-editor.html +48 -0
  28. package/admin/js/templates/pages.html +6 -1
  29. package/admin/js/templates/pro-docs.html +259 -0
  30. package/admin/js/templates/role-editor.html +59 -0
  31. package/admin/js/templates/roles.html +10 -0
  32. package/admin/js/templates/settings.html +137 -18
  33. package/admin/js/templates/tutorials.html +81 -0
  34. package/admin/js/templates/user-editor.html +7 -0
  35. package/admin/js/templates/users.html +3 -1
  36. package/admin/js/templates/view-editor.html +201 -0
  37. package/admin/js/templates/view-preview.html +51 -0
  38. package/admin/js/templates/views-list.html +19 -0
  39. package/admin/js/views/action-editor.js +1 -0
  40. package/admin/js/views/actions-list.js +1 -0
  41. package/admin/js/views/api-reference.js +1 -0
  42. package/admin/js/views/block-editor.js +8 -0
  43. package/admin/js/views/blocks.js +4 -0
  44. package/admin/js/views/collection-editor.js +3 -487
  45. package/admin/js/views/collection-entries.js +1 -484
  46. package/admin/js/views/collections.js +1 -153
  47. package/admin/js/views/dashboard.js +1 -56
  48. package/admin/js/views/documentation.js +1 -12
  49. package/admin/js/views/form-editor.js +8 -0
  50. package/admin/js/views/form-submissions.js +1 -0
  51. package/admin/js/views/forms.js +1 -0
  52. package/admin/js/views/index.js +1 -39
  53. package/admin/js/views/layouts.js +9 -42
  54. package/admin/js/views/login.js +7 -251
  55. package/admin/js/views/media.js +1 -240
  56. package/admin/js/views/my-profile.js +1 -0
  57. package/admin/js/views/navigation.js +14 -212
  58. package/admin/js/views/page-editor.js +72 -661
  59. package/admin/js/views/pages.js +5 -72
  60. package/admin/js/views/plugins.js +13 -90
  61. package/admin/js/views/pro-docs.js +1 -0
  62. package/admin/js/views/role-editor.js +1 -0
  63. package/admin/js/views/roles.js +4 -0
  64. package/admin/js/views/settings.js +3 -199
  65. package/admin/js/views/tutorials.js +1 -12
  66. package/admin/js/views/user-editor.js +1 -88
  67. package/admin/js/views/users.js +4 -76
  68. package/admin/js/views/view-editor.js +1 -0
  69. package/admin/js/views/view-preview.js +1 -0
  70. package/admin/js/views/views-list.js +1 -0
  71. package/bin/cli.js +1 -1
  72. package/config/auth.json +2 -17
  73. package/config/connections.json.bak +9 -0
  74. package/config/connections.json.example +9 -0
  75. package/config/navigation.json +15 -0
  76. package/config/plugins.json +19 -29
  77. package/config/server.json +6 -6
  78. package/config/site.json +17 -6
  79. package/package.json +24 -10
  80. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  81. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  82. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  83. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  84. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  85. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  86. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  87. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  88. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  89. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  90. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  91. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  92. package/plugins/example-analytics/stats.json +21 -12
  93. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  94. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  95. package/plugins/theme-roller/config.js +1 -0
  96. package/plugins/theme-roller/plugin.js +233 -0
  97. package/plugins/theme-roller/plugin.json +31 -0
  98. package/plugins/theme-roller/public/active-theme.css +0 -0
  99. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  100. package/public/css/forms.css +1 -0
  101. package/public/css/site.css +1 -302
  102. package/public/js/btt.js +1 -90
  103. package/public/js/cookie-consent.js +1 -61
  104. package/public/js/form-logic-engine.js +1 -0
  105. package/public/js/forms.js +1 -0
  106. package/public/js/site.js +1 -204
  107. package/scripts/build.js +194 -129
  108. package/scripts/pro.js +254 -0
  109. package/scripts/reset.js +33 -8
  110. package/scripts/seed.js +343 -78
  111. package/scripts/setup.js +5 -4
  112. package/server/middleware/auth.js +136 -97
  113. package/server/routes/api/actions.js +200 -0
  114. package/server/routes/api/auth.js +292 -116
  115. package/server/routes/api/blocks.js +84 -0
  116. package/server/routes/api/collections.js +88 -23
  117. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
  118. package/server/routes/api/layouts.js +49 -25
  119. package/server/routes/api/media.js +118 -93
  120. package/server/routes/api/navigation.js +40 -37
  121. package/server/routes/api/pages.js +132 -118
  122. package/server/routes/api/plugins.js +6 -3
  123. package/server/routes/api/settings.js +104 -89
  124. package/server/routes/api/users.js +27 -21
  125. package/server/routes/api/views.js +148 -0
  126. package/server/routes/public.js +124 -108
  127. package/server/server.js +269 -173
  128. package/server/services/actions.js +387 -0
  129. package/server/services/adapterRegistry.js +98 -0
  130. package/server/services/adapters/FileAdapter.js +192 -0
  131. package/server/services/adapters/MongoAdapter.js +220 -0
  132. package/server/services/blocks.js +162 -0
  133. package/server/services/collections.js +74 -86
  134. package/server/services/connectionManager.js +102 -0
  135. package/server/services/content.js +312 -307
  136. package/{plugins/form-builder → server/services}/email.js +126 -103
  137. package/server/services/forms.js +173 -0
  138. package/server/services/markdown.js +1378 -648
  139. package/server/services/permissionRegistry.js +173 -0
  140. package/server/services/presetCollections.js +251 -0
  141. package/server/services/renderer.js +75 -1
  142. package/server/services/roles.js +227 -0
  143. package/server/services/rowAccess.js +104 -0
  144. package/server/services/userProfiles.js +199 -0
  145. package/server/services/users.js +281 -212
  146. package/server/services/views.js +280 -0
  147. package/server/templates/page.html +119 -113
  148. package/plugins/form-builder/admin/templates/form-editor.html +0 -171
  149. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  150. package/plugins/form-builder/admin/views/form-editor.js +0 -1442
  151. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  152. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  153. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  154. package/plugins/form-builder/config.js +0 -9
  155. package/plugins/form-builder/data/forms/consent.json +0 -104
  156. package/plugins/form-builder/data/forms/contact-details.json +0 -63
  157. package/plugins/form-builder/data/forms/contacts.json +0 -66
  158. package/plugins/form-builder/data/submissions/consent.json +0 -13
  159. package/plugins/form-builder/data/submissions/contact-details.json +0 -1
  160. package/plugins/form-builder/data/submissions/contacts.json +0 -26
  161. package/plugins/form-builder/plugin.json +0 -52
  162. package/plugins/form-builder/public/form-logic-engine.js +0 -568
  163. package/plugins/form-builder/public/inject-body.html +0 -352
  164. package/plugins/form-builder/public/inject-head.html +0 -58
  165. package/plugins/form-builder/public/package.json +0 -1
  166. package/scripts/copy-domma.js +0 -48
@@ -1,25 +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, requireRole } from '../../middleware/auth.js';
8
- import { config } from '../../config.js';
9
-
10
- export async function layoutsRoutes(fastify) {
11
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.layouts)] };
12
-
13
- fastify.get('/layouts', guard, async () => {
14
- return getConfig('presets');
15
- });
16
-
17
- fastify.put('/layouts', guard, async (request, reply) => {
18
- const data = request.body;
19
- if (!data || typeof data !== 'object') {
20
- return reply.status(400).send({ error: 'Invalid presets data' });
21
- }
22
- saveConfig('presets', data);
23
- return { success: true };
24
- });
25
- }
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,93 +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, requireRole} from '../../middleware/auth.js';
11
- import {config} from '../../config.js';
12
-
13
- // Safe filename: strip path traversal and restrict to alphanumeric + safe chars
14
- function sanitiseFilename(name) {
15
- return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
16
- }
17
-
18
- export async function mediaRoutes(fastify) {
19
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.media)] };
20
-
21
- fastify.get('/media', guard, async () => {
22
- return listMedia();
23
- });
24
-
25
- fastify.post('/media', guard, async (request, reply) => {
26
- const results = [];
27
- for await (const data of request.files()) {
28
- const filename = sanitiseFilename(data.filename);
29
- const chunks = [];
30
- for await (const chunk of data.file) {
31
- chunks.push(chunk);
32
- }
33
- results.push(await saveMedia(filename, Buffer.concat(chunks)));
34
- }
35
- if (!results.length) return reply.status(400).send({error: 'No file uploaded'});
36
- return reply.status(201).send(results.length === 1 ? results[0] : results);
37
- });
38
-
39
- fastify.patch('/media/:name', guard, async (request, reply) => {
40
- const oldName = sanitiseFilename(request.params.name);
41
- const newName = sanitiseFilename(request.body?.newName ?? '');
42
- if (!newName) return reply.status(400).send({error: 'newName is required.'});
43
- if (oldName === newName) return reply.status(400).send({error: 'New name is the same as the current name.'});
44
- try {
45
- return await renameMedia(oldName, newName);
46
- } catch (err) {
47
- return reply.status(409).send({error: err.message});
48
- }
49
- });
50
-
51
- fastify.delete('/media/:name', guard, async (request, reply) => {
52
- const name = sanitiseFilename(request.params.name);
53
- await deleteMedia(name);
54
- return { success: true };
55
- });
56
-
57
- fastify.get('/media/:name/info', guard, async (request, reply) => {
58
- const name = sanitiseFilename(request.params.name);
59
- if (!isEditableImage(name)) {
60
- return reply.status(400).send({error: 'Not an editable image format'});
61
- }
62
- try {
63
- return await getImageInfo(name);
64
- } catch {
65
- return reply.status(404).send({error: 'File not found'});
66
- }
67
- });
68
-
69
- fastify.post('/media/:name/transform', guard, async (request, reply) => {
70
- const name = sanitiseFilename(request.params.name);
71
- if (!isEditableImage(name)) {
72
- return reply.status(400).send({error: 'Not an editable image format'});
73
- }
74
-
75
- const {operations = {}, saveAs} = request.body ?? {};
76
-
77
- // Sanitise fields that reference filesystem paths
78
- if (operations.watermark?.image) {
79
- operations.watermark.image = sanitiseFilename(operations.watermark.image);
80
- }
81
- if (operations._deleteOriginal) {
82
- operations._deleteOriginal = sanitiseFilename(operations._deleteOriginal);
83
- }
84
-
85
- const outputFilename = saveAs ? sanitiseFilename(saveAs) : null;
86
-
87
- try {
88
- return await transformImage(name, operations, outputFilename);
89
- } catch (err) {
90
- return reply.status(500).send({error: err.message});
91
- }
92
- });
93
- }
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,37 +1,40 @@
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, requireRole } from '../../middleware/auth.js';
8
- import { config } from '../../config.js';
9
-
10
- export async function navigationRoutes(fastify) {
11
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.navigation)] };
12
-
13
- fastify.get('/navigation', guard, async () => {
14
- return getConfig('navigation');
15
- });
16
-
17
- fastify.put('/navigation', guard, 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
- // Normalise child key: Domma navbar expects `items`, not `children`
23
- if (Array.isArray(data.items)) {
24
- data.items = data.items.map(item => {
25
- const children = item.items || item.children;
26
- if (children?.length) {
27
- const { children: _c, ...rest } = item;
28
- return { ...rest, items: children };
29
- }
30
- const { children: _c, ...rest } = item;
31
- return rest;
32
- });
33
- }
34
- saveConfig('navigation', data);
35
- return { success: true };
36
- });
37
- }
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, requireRole} from '../../middleware/auth.js';
12
- import {config, getConfig, saveConfig} from '../../config.js';
13
-
14
- export async function pagesRoutes(fastify) {
15
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.pages)] };
16
-
17
- // Render markdown preview (shortcodes + sanitize, no frontmatter)
18
- fastify.post('/pages/preview', guard, async (request, reply) => {
19
- const {markdown} = request.body;
20
- if (typeof markdown !== 'string') return reply.status(400).send({error: 'markdown must be a string'});
21
- const {html} = parseMarkdown(`---\ntitle: preview\n---\n${markdown}`);
22
- return {html};
23
- });
24
-
25
- // List all pages
26
- fastify.get('/pages', guard, async (request, reply) => {
27
- const pages = await listPages();
28
- return pages.map(p => ({
29
- urlPath: p.urlPath,
30
- title: p.title,
31
- slug: p.slug,
32
- status: p.status,
33
- layout: p.layout,
34
- showInNav: p.showInNav,
35
- sortOrder: p.sortOrder,
36
- category: p.category || null,
37
- visibility: p.visibility || 'public',
38
- updatedAt: p.updatedAt,
39
- createdAt: p.createdAt
40
- }));
41
- });
42
-
43
- // Get single page
44
- fastify.get('/pages/*', guard, async (request, reply) => {
45
- const urlPath = '/' + request.params['*'];
46
- const page = await getPage(urlPath);
47
- if (!page) return reply.status(404).send({ error: 'Page not found' });
48
- return page;
49
- });
50
-
51
- // Create page
52
- fastify.post('/pages', guard, async (request, reply) => {
53
- const { urlPath, frontmatter, body } = request.body;
54
- if (!urlPath) return reply.status(400).send({ error: 'urlPath is required' });
55
-
56
- const existing = await getPage(urlPath);
57
- if (existing) return reply.status(409).send({ error: 'Page already exists at that path' });
58
-
59
- const page = await createPage(urlPath, frontmatter || {}, body || '');
60
- return reply.status(201).send(page);
61
- });
62
-
63
- // Update page (optionally rename to a new URL path)
64
- fastify.put('/pages/*', guard, async (request, reply) => {
65
- const urlPath = '/' + request.params['*'];
66
- const { frontmatter, body, newUrlPath } = request.body;
67
-
68
- const existing = await getPage(urlPath);
69
- if (!existing) return reply.status(404).send({ error: 'Page not found' });
70
-
71
- if (newUrlPath && newUrlPath !== urlPath) {
72
- const conflict = await getPage(newUrlPath);
73
- if (conflict) return reply.status(409).send({ error: 'A page already exists at that path' });
74
-
75
- await renamePage(urlPath, newUrlPath);
76
- await rewriteNavLinks(urlPath, newUrlPath);
77
-
78
- const page = await updatePage(newUrlPath, frontmatter || {}, body);
79
- return page;
80
- }
81
-
82
- const page = await updatePage(urlPath, frontmatter || {}, body);
83
- return page;
84
- });
85
-
86
- // Delete page
87
- fastify.delete('/pages/*', guard, async (request, reply) => {
88
- const urlPath = '/' + request.params['*'];
89
- const existing = await getPage(urlPath);
90
- if (!existing) return reply.status(404).send({ error: 'Page not found' });
91
-
92
- await deletePage(urlPath);
93
- return { success: true };
94
- });
95
- }
96
-
97
- /**
98
- * Rewrite any navigation item URLs that match oldUrlPath to newUrlPath,
99
- * then persist the updated navigation config.
100
- *
101
- * @param {string} oldUrlPath
102
- * @param {string} newUrlPath
103
- * @returns {Promise<void>}
104
- */
105
- async function rewriteNavLinks(oldUrlPath, newUrlPath) {
106
- const nav = getConfig('navigation');
107
- if (!nav?.items?.length) return;
108
-
109
- let changed = false;
110
- for (const item of nav.items) {
111
- if (item.url === oldUrlPath) { item.url = newUrlPath; changed = true; }
112
- for (const child of item.children || []) {
113
- if (child.url === oldUrlPath) { child.url = newUrlPath; changed = true; }
114
- }
115
- }
116
-
117
- if (changed) await saveConfig('navigation', nav);
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
+ }