domma-cms 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,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, 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
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Plugins API
3
+ * GET /api/plugins - list all discovered plugins + state (admin only)
4
+ * PUT /api/plugins/:name - enable/disable, update settings (admin only)
5
+ * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
6
+ */
7
+ import { authenticate, requireAdmin } from '../../middleware/auth.js';
8
+ import { discoverPlugins, getPluginStates, savePluginState, getAdminPluginConfig } from '../../services/plugins.js';
9
+
10
+ export async function pluginsRoutes(fastify) {
11
+ // List all plugins with their current state
12
+ fastify.get('/plugins', { preHandler: [authenticate, requireAdmin] }, async () => {
13
+ const manifests = await discoverPlugins();
14
+ const states = getPluginStates();
15
+
16
+ return manifests.map(manifest => ({
17
+ name: manifest.name,
18
+ displayName: manifest.displayName || manifest.name,
19
+ version: manifest.version || '1.0.0',
20
+ description: manifest.description || '',
21
+ author: manifest.author || '',
22
+ date: manifest.date || '',
23
+ icon: manifest.icon || 'package',
24
+ enabled: !!(states[manifest.name]?.enabled),
25
+ settings: states[manifest.name]?.settings || {}
26
+ }));
27
+ });
28
+
29
+ // Enable/disable or update settings for a plugin
30
+ fastify.put('/plugins/:name', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
31
+ const { name } = request.params;
32
+ const { enabled, settings } = request.body || {};
33
+
34
+ const manifests = await discoverPlugins();
35
+ const manifest = manifests.find(m => m.name === name);
36
+ if (!manifest) return reply.status(404).send({ error: 'Plugin not found' });
37
+
38
+ savePluginState(name, { enabled: !!enabled, settings: settings || {} });
39
+ return { success: true };
40
+ });
41
+
42
+ // Return merged admin config (sidebar, routes, views) for enabled plugins
43
+ fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async () => {
44
+ return getAdminPluginConfig();
45
+ });
46
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Settings API
3
+ * GET /api/settings - get site settings
4
+ * PUT /api/settings - save site settings
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 settingsRoutes(fastify) {
11
+ const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.settings)] };
12
+
13
+ fastify.get('/settings', guard, async () => {
14
+ return getConfig('site');
15
+ });
16
+
17
+ fastify.put('/settings', guard, async (request, reply) => {
18
+ const data = request.body;
19
+ if (!data || typeof data !== 'object') {
20
+ return reply.status(400).send({ error: 'Invalid settings data' });
21
+ }
22
+ saveConfig('site', data);
23
+ return { success: true };
24
+ });
25
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Users API
3
+ * GET /api/users - list all users (admin, manager)
4
+ * GET /api/users/:id - single user (admin, manager, or self)
5
+ * POST /api/users - create user (admin, manager — manager cannot create admin)
6
+ * PUT /api/users/:id - update user (admin, manager — manager cannot edit admin)
7
+ * DELETE /api/users/:id - delete user (admin, manager — manager cannot delete admin, no self-delete)
8
+ */
9
+ import { authenticate, requireRole, canManageUser } from '../../middleware/auth.js';
10
+ import { config } from '../../config.js';
11
+ import {
12
+ listUsers,
13
+ getUserById,
14
+ createUser,
15
+ updateUser,
16
+ deleteUser
17
+ } from '../../services/users.js';
18
+
19
+ const { permissions } = config.auth;
20
+
21
+ export async function usersRoutes(fastify) {
22
+ const guard = [authenticate, requireRole(permissions.users)];
23
+
24
+ // List all users
25
+ fastify.get('/users', { preHandler: guard }, async () => {
26
+ return listUsers();
27
+ });
28
+
29
+ // Get single user (admin, manager, or the user themselves)
30
+ fastify.get('/users/:id', { preHandler: [authenticate] }, async (request, reply) => {
31
+ const { id } = request.params;
32
+ const actor = request.user;
33
+
34
+ const isSelf = actor.id === id;
35
+ const canManage = permissions.users.includes(actor.role);
36
+
37
+ if (!isSelf && !canManage) {
38
+ return reply.code(403).send({ error: 'Forbidden' });
39
+ }
40
+
41
+ const user = await getUserById(id);
42
+ if (!user) return reply.status(404).send({ error: 'User not found' });
43
+ return user;
44
+ });
45
+
46
+ // Create user
47
+ fastify.post('/users', { preHandler: guard }, async (request, reply) => {
48
+ const { name, email, password, role } = request.body || {};
49
+ if (!name || !email || !password) {
50
+ return reply.status(400).send({ error: 'name, email and password are required' });
51
+ }
52
+ if (password.length < 8) {
53
+ return reply.status(400).send({ error: 'Password must be at least 8 characters' });
54
+ }
55
+
56
+ const targetRole = role || 'editor';
57
+ if (!canManageUser(request.user.role, targetRole)) {
58
+ return reply.code(403).send({ error: 'You cannot create a user with that role' });
59
+ }
60
+
61
+ try {
62
+ const user = await createUser({ name, email, password, role: targetRole });
63
+ return reply.status(201).send(user);
64
+ } catch (err) {
65
+ return reply.status(409).send({ error: err.message });
66
+ }
67
+ });
68
+
69
+ // Update user
70
+ fastify.put('/users/:id', { preHandler: guard }, async (request, reply) => {
71
+ const { id } = request.params;
72
+ const actor = request.user;
73
+
74
+ const target = await getUserById(id);
75
+ if (!target) return reply.status(404).send({ error: 'User not found' });
76
+
77
+ if (!canManageUser(actor.role, target.role)) {
78
+ return reply.code(403).send({ error: 'You cannot edit a user with that role' });
79
+ }
80
+
81
+ const updates = request.body || {};
82
+ // Prevent role escalation beyond what the actor can manage
83
+ if (updates.role && !canManageUser(actor.role, updates.role)) {
84
+ return reply.code(403).send({ error: 'You cannot assign that role' });
85
+ }
86
+
87
+ const user = await updateUser(id, updates);
88
+ return user;
89
+ });
90
+
91
+ // Delete user
92
+ fastify.delete('/users/:id', { preHandler: guard }, async (request, reply) => {
93
+ const { id } = request.params;
94
+ const actor = request.user;
95
+
96
+ if (actor.id === id) {
97
+ return reply.code(403).send({ error: 'You cannot delete your own account' });
98
+ }
99
+
100
+ const target = await getUserById(id);
101
+ if (!target) return reply.status(404).send({ error: 'User not found' });
102
+
103
+ if (!canManageUser(actor.role, target.role)) {
104
+ return reply.code(403).send({ error: 'You cannot delete a user with that role' });
105
+ }
106
+
107
+ await deleteUser(id);
108
+ return { success: true };
109
+ });
110
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Public Site Routes
3
+ * Catch-all that resolves URL paths to Markdown pages and renders them server-side.
4
+ * Draft pages are not served publicly.
5
+ * The admin panel is excluded (handled by static serving).
6
+ */
7
+ import {getPage} from '../services/content.js';
8
+ import {renderPage} from '../services/renderer.js';
9
+ import {config} from '../config.js';
10
+
11
+ export async function publicRoutes(fastify) {
12
+ // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
13
+ fastify.get('/admin', async (request, reply) => {
14
+ return reply.redirect('/admin/');
15
+ });
16
+
17
+ // Health check
18
+ fastify.get('/api/health', async () => ({ status: 'ok' }));
19
+
20
+ // Public pages catch-all
21
+ fastify.get('/*', async (request, reply) => {
22
+ const rawPath = request.params['*'];
23
+
24
+ // Skip non-page paths (assets handled by static plugin)
25
+ if (rawPath.includes('.')) {
26
+ return reply.callNotFound();
27
+ }
28
+
29
+ const urlPath = '/' + (rawPath || '');
30
+
31
+ // Try exact path first, then with /index suffix for directory-style URLs
32
+ let page = await getPage(urlPath);
33
+
34
+ // Try fetching index of a directory path
35
+ if (!page && !urlPath.endsWith('/index')) {
36
+ page = await getPage(urlPath.replace(/\/$/, '') || '/');
37
+ }
38
+
39
+ if (!page) {
40
+ reply.status(404);
41
+ return reply.type('text/html').send(await render404(urlPath));
42
+ }
43
+
44
+ // Don't serve draft pages publicly
45
+ if (page.status !== 'published') {
46
+ reply.status(404);
47
+ return reply.type('text/html').send(await render404(urlPath));
48
+ }
49
+
50
+ // Enforce page visibility
51
+ if (page.visibility && page.visibility !== 'public') {
52
+ let userRole = null;
53
+ try {
54
+ const decoded = await request.jwtVerify();
55
+ userRole = decoded.role;
56
+ } catch { /* no token — treat as unauthenticated */
57
+ }
58
+
59
+ const roles = config.auth.roles;
60
+ const userLevel = roles[userRole]?.level ?? Infinity;
61
+ const requiredLevel = roles[page.visibility]?.level ?? 0;
62
+
63
+ if (userLevel > requiredLevel) {
64
+ reply.status(403);
65
+ return reply.type('text/html').send(accessDeniedHtml(urlPath));
66
+ }
67
+ }
68
+
69
+ const html = await renderPage(page);
70
+ return reply.type('text/html').send(html);
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Render a 404 response — tries content/pages/404.md first, falls back to
76
+ * a minimal inline page so the site theme is applied when possible.
77
+ */
78
+ async function render404(urlPath) {
79
+ try {
80
+ const page404 = await getPage('/404');
81
+ if (page404 && page404.status === 'published') {
82
+ return await renderPage(page404);
83
+ }
84
+ } catch { /* fall through */ }
85
+ return notFoundHtml(urlPath);
86
+ }
87
+
88
+ function accessDeniedHtml(urlPath) {
89
+ return `<!DOCTYPE html>
90
+ <html lang="en-GB">
91
+ <head><meta charset="UTF-8"><title>403 Access Denied</title>
92
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;}
93
+ .box{text-align:center;} h1{font-size:6rem;margin:0;color:#666;} p{color:#999;}</style>
94
+ </head>
95
+ <body><div class="box"><h1>403</h1><p>You don't have permission to view <code>${urlPath}</code></p><p><a href="/" style="color:#aaa">Go home</a></p></div></body>
96
+ </html>`;
97
+ }
98
+
99
+ function notFoundHtml(urlPath) {
100
+ return `<!DOCTYPE html>
101
+ <html lang="en-GB">
102
+ <head><meta charset="UTF-8"><title>404 Not Found</title>
103
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;}
104
+ .box{text-align:center;} h1{font-size:6rem;margin:0;color:#666;} p{color:#999;}</style>
105
+ </head>
106
+ <body><div class="box"><h1>404</h1><p>No page found at <code>${urlPath}</code></p><p><a href="/" style="color:#aaa">Go home</a></p></div></body>
107
+ </html>`;
108
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Domma CMS - Fastify Server
3
+ * Serves the admin SPA, public site, REST API, and static assets.
4
+ * Domma dist files are served directly from node_modules/domma-js/public/dist.
5
+ */
6
+ import 'dotenv/config';
7
+ import Fastify from 'fastify';
8
+ import jwt from '@fastify/jwt';
9
+ import staticPlugin from '@fastify/static';
10
+ import cors from '@fastify/cors';
11
+ import multipart from '@fastify/multipart';
12
+ import path from 'path';
13
+ import fs from 'fs/promises';
14
+ import {fileURLToPath} from 'url';
15
+ import {createRequire} from 'module';
16
+ import {config} from './config.js';
17
+ import {registerPlugins} from './services/plugins.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const ROOT = path.resolve(__dirname, '..');
21
+
22
+ // Resolve domma-js package location via require resolution
23
+ const require = createRequire(import.meta.url);
24
+ const dommaPackageDir = path.dirname(require.resolve('domma-js/package.json'));
25
+ const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
26
+
27
+ const { server: serverConfig, auth: authConfig } = config;
28
+
29
+ // Validate JWT_SECRET before starting — prevents silent auth failures
30
+ const JWT_SECRET = process.env.JWT_SECRET;
31
+ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
32
+ console.error('');
33
+ console.error(' ERROR: JWT_SECRET is not set or is insecure.');
34
+ console.error(' Run `npm run setup` or set a secure JWT_SECRET in .env');
35
+ console.error(' (minimum 32 characters, not "CHANGE_ME")');
36
+ console.error('');
37
+ process.exit(1);
38
+ }
39
+
40
+ const app = Fastify({
41
+ logger: { level: process.env.NODE_ENV === 'development' ? 'info' : 'warn' }
42
+ });
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Core Plugins
46
+ // ---------------------------------------------------------------------------
47
+
48
+ await app.register(jwt, { secret: process.env.JWT_SECRET });
49
+ await app.register(cors, serverConfig.cors);
50
+ await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Static Assets
54
+ // ---------------------------------------------------------------------------
55
+
56
+ // Serve Domma dist files from npm package (shared by admin + public)
57
+ await app.register(staticPlugin, {
58
+ root: DOMMA_DIST,
59
+ prefix: '/dist/domma/',
60
+ decorateReply: false
61
+ });
62
+
63
+ // Serve public site assets (CSS, JS, images, etc.)
64
+ await app.register(staticPlugin, {
65
+ root: path.join(ROOT, 'public'),
66
+ prefix: '/public/',
67
+ decorateReply: false
68
+ });
69
+
70
+ // Serve admin panel assets
71
+ await app.register(staticPlugin, {
72
+ root: path.join(ROOT, 'admin'),
73
+ prefix: '/admin/',
74
+ decorateReply: false
75
+ });
76
+
77
+ // Ensure required directories exist
78
+ const mediaDir = path.join(ROOT, config.content.mediaDir);
79
+ const usersDir = path.join(ROOT, config.content.usersDir);
80
+ const pluginsDir = path.join(ROOT, 'plugins');
81
+ await fs.mkdir(mediaDir, { recursive: true });
82
+ await fs.mkdir(usersDir, { recursive: true });
83
+ await fs.mkdir(pluginsDir, { recursive: true });
84
+
85
+ // Serve uploaded media files
86
+ await app.register(staticPlugin, {
87
+ root: mediaDir,
88
+ prefix: '/media/',
89
+ decorateReply: false
90
+ });
91
+
92
+ // Serve plugin admin/ and public/ subdirs only — block plugin.js, config.js, data/, etc.
93
+ await app.register(async function pluginStaticScope(instance) {
94
+ instance.addHook('onRequest', (request, reply, done) => {
95
+ const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
96
+ const segments = relative.split('/');
97
+
98
+ if (relative.includes('..')) {
99
+ reply.code(403).send({error: 'Forbidden'});
100
+ return;
101
+ }
102
+ // Must be: {pluginName}/{admin|public}/{file...}
103
+ if (segments.length < 3 || !['admin', 'public'].includes(segments[1])) {
104
+ reply.code(404).send({error: 'Not found'});
105
+ return;
106
+ }
107
+ done();
108
+ });
109
+
110
+ await instance.register(staticPlugin, {
111
+ root: pluginsDir,
112
+ prefix: '/plugins/',
113
+ decorateReply: false
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Auth API Routes (no authentication required on these endpoints themselves)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ const { authRoutes } = await import('./routes/api/auth.js');
122
+ await app.register(authRoutes, { prefix: '/api' });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Protected API Routes
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const { pagesRoutes } = await import('./routes/api/pages.js');
129
+ const { settingsRoutes } = await import('./routes/api/settings.js');
130
+ const { layoutsRoutes } = await import('./routes/api/layouts.js');
131
+ const { navigationRoutes } = await import('./routes/api/navigation.js');
132
+ const { mediaRoutes } = await import('./routes/api/media.js');
133
+ const { usersRoutes } = await import('./routes/api/users.js');
134
+ const { pluginsRoutes } = await import('./routes/api/plugins.js');
135
+
136
+ await app.register(pagesRoutes, { prefix: '/api' });
137
+ await app.register(settingsRoutes, { prefix: '/api' });
138
+ await app.register(layoutsRoutes, { prefix: '/api' });
139
+ await app.register(navigationRoutes, { prefix: '/api' });
140
+ await app.register(mediaRoutes, { prefix: '/api' });
141
+ await app.register(usersRoutes, { prefix: '/api' });
142
+ await app.register(pluginsRoutes, { prefix: '/api' });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // CMS Plugins (server-side Fastify plugins from plugins/ directory)
146
+ // ---------------------------------------------------------------------------
147
+
148
+ await registerPlugins(app);
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Public Site (catch-all — must be last)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const { publicRoutes } = await import('./routes/public.js');
155
+ await app.register(publicRoutes);
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Start
159
+ // ---------------------------------------------------------------------------
160
+
161
+ try {
162
+ await app.listen({ port: serverConfig.port, host: serverConfig.host });
163
+ console.log(`Domma CMS running at http://localhost:${serverConfig.port}`);
164
+ console.log(` Admin: http://localhost:${serverConfig.port}/admin/`);
165
+ console.log(` Public: http://localhost:${serverConfig.port}/`);
166
+ } catch (err) {
167
+ app.log.error(err);
168
+ process.exit(1);
169
+ }