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.
Files changed (150) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1
  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 -1
  7. package/admin/js/app.js +8 -4
  8. package/admin/js/config/sidebar-config.js +1 -1
  9. package/admin/js/lib/markdown-toolbar.js +18 -10
  10. package/admin/js/templates/action-editor.html +171 -0
  11. package/admin/js/templates/actions-list.html +19 -0
  12. package/admin/js/templates/api-reference.html +1411 -0
  13. package/admin/js/templates/block-editor.html +158 -0
  14. package/admin/js/templates/blocks.html +8 -0
  15. package/admin/js/templates/collection-editor.html +47 -0
  16. package/admin/js/templates/collection-entries.html +3 -0
  17. package/admin/js/templates/collections.html +51 -4
  18. package/admin/js/templates/documentation.html +258 -0
  19. package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
  20. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  21. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  22. package/admin/js/templates/login.html +29 -4
  23. package/admin/js/templates/my-profile.html +17 -0
  24. package/admin/js/templates/page-editor.html +39 -0
  25. package/admin/js/templates/pages.html +6 -1
  26. package/admin/js/templates/pro-docs.html +259 -0
  27. package/admin/js/templates/role-editor.html +59 -0
  28. package/admin/js/templates/roles.html +10 -0
  29. package/admin/js/templates/settings.html +167 -23
  30. package/admin/js/templates/tutorials.html +81 -0
  31. package/admin/js/templates/user-editor.html +7 -0
  32. package/admin/js/templates/users.html +3 -26
  33. package/admin/js/templates/view-editor.html +201 -0
  34. package/admin/js/templates/view-preview.html +51 -0
  35. package/admin/js/templates/views-list.html +19 -0
  36. package/admin/js/views/action-editor.js +1 -0
  37. package/admin/js/views/actions-list.js +1 -0
  38. package/admin/js/views/api-reference.js +1 -0
  39. package/admin/js/views/block-editor.js +8 -0
  40. package/admin/js/views/blocks.js +4 -0
  41. package/admin/js/views/collection-editor.js +3 -3
  42. package/admin/js/views/collection-entries.js +1 -1
  43. package/admin/js/views/collections.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +8 -0
  46. package/admin/js/views/form-submissions.js +1 -0
  47. package/admin/js/views/forms.js +1 -0
  48. package/admin/js/views/index.js +1 -1
  49. package/admin/js/views/login.js +2 -2
  50. package/admin/js/views/media.js +1 -1
  51. package/admin/js/views/my-profile.js +1 -0
  52. package/admin/js/views/page-editor.js +34 -15
  53. package/admin/js/views/pages.js +5 -5
  54. package/admin/js/views/plugins.js +10 -10
  55. package/admin/js/views/pro-docs.js +1 -0
  56. package/admin/js/views/role-editor.js +1 -0
  57. package/admin/js/views/roles.js +4 -0
  58. package/admin/js/views/settings.js +3 -1
  59. package/admin/js/views/user-editor.js +1 -1
  60. package/admin/js/views/users.js +4 -7
  61. package/admin/js/views/view-editor.js +1 -0
  62. package/admin/js/views/view-preview.js +1 -0
  63. package/admin/js/views/views-list.js +1 -0
  64. package/bin/cli.js +1 -1
  65. package/config/auth.json +1 -0
  66. package/config/connections.json.bak +9 -0
  67. package/config/connections.json.example +9 -0
  68. package/config/navigation.json +5 -15
  69. package/config/plugins.json +19 -29
  70. package/config/server.json +6 -6
  71. package/config/site.json +16 -6
  72. package/package.json +25 -10
  73. package/plugins/example-analytics/stats.json +17 -12
  74. package/plugins/form-builder/data/forms/contacts.json +62 -62
  75. package/plugins/form-builder/data/forms/enquiries.json +103 -0
  76. package/plugins/form-builder/data/forms/feedback.json +17 -16
  77. package/plugins/form-builder/data/forms/notes.json +79 -0
  78. package/plugins/form-builder/data/forms/to-do.json +100 -0
  79. package/plugins/form-builder/data/submissions/contacts.json +1 -26
  80. package/plugins/form-builder/data/submissions/notes.json +1 -0
  81. package/plugins/form-builder/data/submissions/to-do.json +1 -0
  82. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  83. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  84. package/plugins/theme-roller/config.js +1 -0
  85. package/plugins/theme-roller/plugin.js +233 -0
  86. package/plugins/theme-roller/plugin.json +31 -0
  87. package/plugins/theme-roller/public/active-theme.css +0 -0
  88. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  89. package/public/css/forms.css +1 -0
  90. package/public/css/site.css +1 -1
  91. package/public/js/forms.js +1 -0
  92. package/public/js/site.js +1 -1
  93. package/scripts/build.js +194 -129
  94. package/scripts/pro.js +254 -0
  95. package/scripts/reset.js +33 -8
  96. package/scripts/seed.js +677 -128
  97. package/scripts/setup.js +1 -0
  98. package/server/middleware/auth.js +136 -120
  99. package/server/routes/api/actions.js +200 -0
  100. package/server/routes/api/auth.js +292 -146
  101. package/server/routes/api/blocks.js +84 -0
  102. package/server/routes/api/collections.js +79 -27
  103. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
  104. package/server/routes/api/layouts.js +49 -39
  105. package/server/routes/api/media.js +118 -92
  106. package/server/routes/api/navigation.js +40 -36
  107. package/server/routes/api/pages.js +132 -118
  108. package/server/routes/api/plugins.js +6 -3
  109. package/server/routes/api/settings.js +104 -88
  110. package/server/routes/api/users.js +27 -19
  111. package/server/routes/api/views.js +148 -0
  112. package/server/routes/public.js +124 -108
  113. package/server/server.js +269 -181
  114. package/server/services/actions.js +387 -0
  115. package/server/services/adapterRegistry.js +98 -0
  116. package/server/services/adapters/FileAdapter.js +192 -0
  117. package/server/services/adapters/MongoAdapter.js +220 -0
  118. package/server/services/blocks.js +162 -0
  119. package/server/services/collections.js +74 -86
  120. package/server/services/connectionManager.js +102 -0
  121. package/server/services/content.js +312 -307
  122. package/server/services/email.js +126 -0
  123. package/server/services/forms.js +173 -0
  124. package/server/services/markdown.js +1378 -747
  125. package/server/services/permissionRegistry.js +173 -0
  126. package/server/services/presetCollections.js +251 -0
  127. package/server/services/renderer.js +98 -2
  128. package/server/services/roles.js +227 -0
  129. package/server/services/rowAccess.js +104 -0
  130. package/server/services/userProfiles.js +199 -0
  131. package/server/services/users.js +281 -212
  132. package/server/services/views.js +280 -0
  133. package/server/templates/page.html +124 -113
  134. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  135. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  136. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  137. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  138. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  139. package/plugins/form-builder/config.js +0 -9
  140. package/plugins/form-builder/data/forms/consent.json +0 -104
  141. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  142. package/plugins/form-builder/data/submissions/consent.json +0 -13
  143. package/plugins/form-builder/plugin.json +0 -52
  144. package/plugins/form-builder/public/inject-body.html +0 -352
  145. package/plugins/form-builder/public/inject-head.html +0 -58
  146. package/plugins/form-builder/public/package.json +0 -1
  147. package/scripts/copy-domma.js +0 -48
  148. package/server/services/userTypes.js +0 -167
  149. /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
  150. /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 guard = { preHandler: [authenticate, requirePermission('layouts')] };
11
-
12
- fastify.get('/layouts', guard, async () => {
13
- return getConfig('presets');
14
- });
15
-
16
- fastify.put('/layouts', guard, async (request, reply) => {
17
- const data = request.body;
18
- if (!data || typeof data !== 'object') {
19
- return reply.status(400).send({ error: 'Invalid presets data' });
20
- }
21
- saveConfig('presets', data);
22
- return { success: true };
23
- });
24
-
25
- fastify.get('/layouts/options', guard, async () => {
26
- return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
27
- });
28
-
29
- fastify.put('/layouts/options', guard, async (request, reply) => {
30
- const data = request.body;
31
- if (!data || typeof data !== 'object') {
32
- return reply.status(400).send({error: 'Invalid layout options'});
33
- }
34
- const site = getConfig('site');
35
- site.layoutOptions = {...site.layoutOptions, ...data};
36
- saveConfig('site', site);
37
- return {success: true};
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
- // Safe filename: strip path traversal and restrict to alphanumeric + safe chars
13
- function sanitiseFilename(name) {
14
- return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
15
- }
16
-
17
- export async function mediaRoutes(fastify) {
18
- const guard = { preHandler: [authenticate, requirePermission('media')] };
19
-
20
- fastify.get('/media', guard, async () => {
21
- return listMedia();
22
- });
23
-
24
- fastify.post('/media', guard, async (request, reply) => {
25
- const results = [];
26
- for await (const data of request.files()) {
27
- const filename = sanitiseFilename(data.filename);
28
- const chunks = [];
29
- for await (const chunk of data.file) {
30
- chunks.push(chunk);
31
- }
32
- results.push(await saveMedia(filename, Buffer.concat(chunks)));
33
- }
34
- if (!results.length) return reply.status(400).send({error: 'No file uploaded'});
35
- return reply.status(201).send(results.length === 1 ? results[0] : results);
36
- });
37
-
38
- fastify.patch('/media/:name', guard, async (request, reply) => {
39
- const oldName = sanitiseFilename(request.params.name);
40
- const newName = sanitiseFilename(request.body?.newName ?? '');
41
- if (!newName) return reply.status(400).send({error: 'newName is required.'});
42
- if (oldName === newName) return reply.status(400).send({error: 'New name is the same as the current name.'});
43
- try {
44
- return await renameMedia(oldName, newName);
45
- } catch (err) {
46
- return reply.status(409).send({error: err.message});
47
- }
48
- });
49
-
50
- fastify.delete('/media/:name', guard, async (request, reply) => {
51
- const name = sanitiseFilename(request.params.name);
52
- await deleteMedia(name);
53
- return { success: true };
54
- });
55
-
56
- fastify.get('/media/:name/info', guard, async (request, reply) => {
57
- const name = sanitiseFilename(request.params.name);
58
- if (!isEditableImage(name)) {
59
- return reply.status(400).send({error: 'Not an editable image format'});
60
- }
61
- try {
62
- return await getImageInfo(name);
63
- } catch {
64
- return reply.status(404).send({error: 'File not found'});
65
- }
66
- });
67
-
68
- fastify.post('/media/:name/transform', guard, async (request, reply) => {
69
- const name = sanitiseFilename(request.params.name);
70
- if (!isEditableImage(name)) {
71
- return reply.status(400).send({error: 'Not an editable image format'});
72
- }
73
-
74
- const {operations = {}, saveAs} = request.body ?? {};
75
-
76
- // Sanitise fields that reference filesystem paths
77
- if (operations.watermark?.image) {
78
- operations.watermark.image = sanitiseFilename(operations.watermark.image);
79
- }
80
- if (operations._deleteOriginal) {
81
- operations._deleteOriginal = sanitiseFilename(operations._deleteOriginal);
82
- }
83
-
84
- const outputFilename = saveAs ? sanitiseFilename(saveAs) : null;
85
-
86
- try {
87
- return await transformImage(name, operations, outputFilename);
88
- } catch (err) {
89
- return reply.status(500).send({error: err.message});
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 { getConfig, saveConfig } from '../../config.js';
7
- import { authenticate, requirePermission } from '../../middleware/auth.js';
8
-
9
- export async function navigationRoutes(fastify) {
10
- const guard = { preHandler: [authenticate, requirePermission('navigation')] };
11
-
12
- fastify.get('/navigation', guard, async () => {
13
- return getConfig('navigation');
14
- });
15
-
16
- fastify.put('/navigation', guard, async (request, reply) => {
17
- const data = request.body;
18
- if (!data || typeof data !== 'object') {
19
- return reply.status(400).send({ error: 'Invalid navigation data' });
20
- }
21
- // Normalise child key: Domma navbar expects `items`, not `children`
22
- if (Array.isArray(data.items)) {
23
- data.items = data.items.map(item => {
24
- const children = item.items || item.children;
25
- if (children?.length) {
26
- const { children: _c, ...rest } = item;
27
- return { ...rest, items: children };
28
- }
29
- const { children: _c, ...rest } = item;
30
- return rest;
31
- });
32
- }
33
- saveConfig('navigation', data);
34
- return { success: true };
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 guard = { preHandler: [authenticate, requirePermission('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
+ }