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.
- package/LICENSE +21 -0
- package/README.md +469 -0
- package/admin/css/admin.css +1123 -0
- package/admin/index.html +72 -0
- package/admin/js/api.js +210 -0
- package/admin/js/app.js +270 -0
- package/admin/js/config/sidebar-config.js +107 -0
- package/admin/js/lib/card.js +63 -0
- package/admin/js/lib/image-editor.js +869 -0
- package/admin/js/lib/markdown-toolbar.js +421 -0
- package/admin/js/templates/dashboard.html +50 -0
- package/admin/js/templates/documentation.html +237 -0
- package/admin/js/templates/layouts.html +11 -0
- package/admin/js/templates/login.html +58 -0
- package/admin/js/templates/media.html +16 -0
- package/admin/js/templates/navigation.html +50 -0
- package/admin/js/templates/page-editor.html +126 -0
- package/admin/js/templates/pages.html +18 -0
- package/admin/js/templates/plugins.html +12 -0
- package/admin/js/templates/settings.html +190 -0
- package/admin/js/templates/tutorials.html +233 -0
- package/admin/js/templates/user-editor.html +12 -0
- package/admin/js/templates/users.html +10 -0
- package/admin/js/views/dashboard.js +48 -0
- package/admin/js/views/documentation.js +12 -0
- package/admin/js/views/index.js +33 -0
- package/admin/js/views/layouts.js +49 -0
- package/admin/js/views/login.js +254 -0
- package/admin/js/views/media.js +240 -0
- package/admin/js/views/navigation.js +152 -0
- package/admin/js/views/page-editor.js +479 -0
- package/admin/js/views/pages.js +64 -0
- package/admin/js/views/plugins.js +100 -0
- package/admin/js/views/settings.js +64 -0
- package/admin/js/views/tutorials.js +12 -0
- package/admin/js/views/user-editor.js +88 -0
- package/admin/js/views/users.js +73 -0
- package/bin/cli.js +334 -0
- package/config/auth.json +20 -0
- package/config/content.json +10 -0
- package/config/navigation.json +63 -0
- package/config/plugins.json +47 -0
- package/config/presets.json +34 -0
- package/config/server.json +6 -0
- package/config/site.json +33 -0
- package/package.json +67 -0
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
- package/plugins/back-to-top/config.js +10 -0
- package/plugins/back-to-top/plugin.js +24 -0
- package/plugins/back-to-top/plugin.json +36 -0
- package/plugins/back-to-top/public/inject-body.html +105 -0
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
- package/plugins/cookie-consent/config.js +30 -0
- package/plugins/cookie-consent/plugin.js +24 -0
- package/plugins/cookie-consent/plugin.json +36 -0
- package/plugins/cookie-consent/public/inject-body.html +69 -0
- package/plugins/custom-css/admin/templates/custom-css.html +17 -0
- package/plugins/custom-css/admin/views/custom-css.js +35 -0
- package/plugins/custom-css/config.js +1 -0
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +63 -0
- package/plugins/custom-css/plugin.json +32 -0
- package/plugins/custom-css/public/inject-head.html +1 -0
- package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
- package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
- package/plugins/domma-effects/config.js +9 -0
- package/plugins/domma-effects/plugin.js +22 -0
- package/plugins/domma-effects/plugin.json +36 -0
- package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
- package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
- package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
- package/plugins/domma-effects/public/celebrations/index.js +535 -0
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
- package/plugins/domma-effects/public/inject-body.html +268 -0
- package/plugins/example-analytics/admin/templates/analytics.html +10 -0
- package/plugins/example-analytics/admin/views/analytics.js +51 -0
- package/plugins/example-analytics/config.js +6 -0
- package/plugins/example-analytics/plugin.js +58 -0
- package/plugins/example-analytics/plugin.json +27 -0
- package/plugins/example-analytics/public/inject-body.html +13 -0
- package/plugins/example-analytics/public/inject-head.html +1 -0
- package/plugins/example-analytics/stats.json +1 -0
- package/plugins/form-builder/admin/templates/form-editor.html +158 -0
- package/plugins/form-builder/admin/templates/form-settings.html +29 -0
- package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
- package/plugins/form-builder/admin/templates/forms-list.html +17 -0
- package/plugins/form-builder/admin/views/form-editor.js +817 -0
- package/plugins/form-builder/admin/views/form-settings.js +38 -0
- package/plugins/form-builder/admin/views/form-submissions.js +295 -0
- package/plugins/form-builder/admin/views/forms-list.js +164 -0
- package/plugins/form-builder/config.js +9 -0
- package/plugins/form-builder/data/forms/contact-details.json +63 -0
- package/plugins/form-builder/data/forms/contact.json +52 -0
- package/plugins/form-builder/data/submissions/contact-details.json +1 -0
- package/plugins/form-builder/data/submissions/contact.json +14 -0
- package/plugins/form-builder/email.js +103 -0
- package/plugins/form-builder/plugin.js +454 -0
- package/plugins/form-builder/plugin.json +56 -0
- package/plugins/form-builder/public/inject-body.html +270 -0
- package/plugins/form-builder/public/inject-head.html +42 -0
- package/public/css/site.css +189 -0
- package/public/js/site.js +109 -0
- package/scripts/copy-domma.js +48 -0
- package/scripts/fresh.js +41 -0
- package/scripts/reset.js +124 -0
- package/scripts/seed.js +666 -0
- package/scripts/setup.js +263 -0
- package/server/config.js +56 -0
- package/server/middleware/auth.js +97 -0
- package/server/routes/api/auth.js +116 -0
- package/server/routes/api/layouts.js +25 -0
- package/server/routes/api/media.js +93 -0
- package/server/routes/api/navigation.js +37 -0
- package/server/routes/api/pages.js +118 -0
- package/server/routes/api/plugins.js +46 -0
- package/server/routes/api/settings.js +25 -0
- package/server/routes/api/users.js +110 -0
- package/server/routes/public.js +108 -0
- package/server/server.js +169 -0
- package/server/services/content.js +298 -0
- package/server/services/images.js +334 -0
- package/server/services/markdown.js +297 -0
- package/server/services/plugins.js +246 -0
- package/server/services/renderer.js +80 -0
- package/server/services/users.js +212 -0
- 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
|
+
}
|
package/server/server.js
ADDED
|
@@ -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
|
+
}
|