domma-cms 0.3.0 → 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 (145) 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 +123 -21
  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/plugins.json +19 -29
  69. package/config/server.json +6 -6
  70. package/config/site.json +12 -2
  71. package/package.json +24 -10
  72. package/plugins/example-analytics/stats.json +17 -12
  73. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  74. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  75. package/plugins/theme-roller/config.js +1 -0
  76. package/plugins/theme-roller/plugin.js +233 -0
  77. package/plugins/theme-roller/plugin.json +31 -0
  78. package/plugins/theme-roller/public/active-theme.css +0 -0
  79. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  80. package/public/css/forms.css +1 -0
  81. package/public/css/site.css +1 -1
  82. package/public/js/forms.js +1 -0
  83. package/public/js/site.js +1 -1
  84. package/scripts/build.js +194 -129
  85. package/scripts/pro.js +254 -0
  86. package/scripts/reset.js +33 -8
  87. package/scripts/seed.js +343 -78
  88. package/scripts/setup.js +1 -0
  89. package/server/middleware/auth.js +136 -120
  90. package/server/routes/api/actions.js +200 -0
  91. package/server/routes/api/auth.js +292 -146
  92. package/server/routes/api/blocks.js +84 -0
  93. package/server/routes/api/collections.js +79 -27
  94. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
  95. package/server/routes/api/layouts.js +49 -39
  96. package/server/routes/api/media.js +118 -92
  97. package/server/routes/api/navigation.js +40 -36
  98. package/server/routes/api/pages.js +132 -118
  99. package/server/routes/api/plugins.js +6 -3
  100. package/server/routes/api/settings.js +104 -88
  101. package/server/routes/api/users.js +27 -19
  102. package/server/routes/api/views.js +148 -0
  103. package/server/routes/public.js +124 -108
  104. package/server/server.js +269 -181
  105. package/server/services/actions.js +387 -0
  106. package/server/services/adapterRegistry.js +98 -0
  107. package/server/services/adapters/FileAdapter.js +192 -0
  108. package/server/services/adapters/MongoAdapter.js +220 -0
  109. package/server/services/blocks.js +162 -0
  110. package/server/services/collections.js +74 -86
  111. package/server/services/connectionManager.js +102 -0
  112. package/server/services/content.js +312 -307
  113. package/server/services/email.js +126 -0
  114. package/server/services/forms.js +173 -0
  115. package/server/services/markdown.js +1378 -747
  116. package/server/services/permissionRegistry.js +173 -0
  117. package/server/services/presetCollections.js +251 -0
  118. package/server/services/renderer.js +75 -1
  119. package/server/services/roles.js +227 -0
  120. package/server/services/rowAccess.js +104 -0
  121. package/server/services/userProfiles.js +199 -0
  122. package/server/services/users.js +281 -212
  123. package/server/services/views.js +280 -0
  124. package/server/templates/page.html +119 -113
  125. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  126. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  127. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  128. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  129. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  130. package/plugins/form-builder/config.js +0 -9
  131. package/plugins/form-builder/data/forms/consent.json +0 -104
  132. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  133. package/plugins/form-builder/data/forms/contacts.json +0 -66
  134. package/plugins/form-builder/data/forms/feedback.json +0 -130
  135. package/plugins/form-builder/data/submissions/consent.json +0 -13
  136. package/plugins/form-builder/data/submissions/contact-details.json +0 -1
  137. package/plugins/form-builder/data/submissions/contacts.json +0 -26
  138. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  139. package/plugins/form-builder/plugin.json +0 -52
  140. package/plugins/form-builder/public/inject-body.html +0 -352
  141. package/plugins/form-builder/public/inject-head.html +0 -58
  142. package/plugins/form-builder/public/package.json +0 -1
  143. package/scripts/copy-domma.js +0 -48
  144. package/server/services/userTypes.js +0 -167
  145. /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
+ }