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
package/scripts/setup.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Domma CMS — Interactive Setup Wizard
|
|
4
|
+
*
|
|
5
|
+
* Run once after cloning: npm run setup
|
|
6
|
+
* Operates directly on files — does NOT import the Fastify server.
|
|
7
|
+
*
|
|
8
|
+
* Steps:
|
|
9
|
+
* 1. Generate JWT_SECRET (skips if already set)
|
|
10
|
+
* 2. Create admin account (skips if users already exist)
|
|
11
|
+
* 3. Set site title and tagline
|
|
12
|
+
* 4. Pick a theme
|
|
13
|
+
*/
|
|
14
|
+
import { createInterface } from 'node:readline/promises';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import bcrypt from 'bcryptjs';
|
|
21
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
25
|
+
|
|
26
|
+
const ENV_FILE = path.join(ROOT, '.env');
|
|
27
|
+
const SITE_CONFIG = path.join(ROOT, 'config', 'site.json');
|
|
28
|
+
const NAV_CONFIG = path.join(ROOT, 'config', 'navigation.json');
|
|
29
|
+
const USERS_DIR = path.join(ROOT, 'content', 'users');
|
|
30
|
+
|
|
31
|
+
const BCRYPT_ROUNDS = 10;
|
|
32
|
+
|
|
33
|
+
const THEMES = [
|
|
34
|
+
'charcoal-dark', 'charcoal-light',
|
|
35
|
+
'ocean-dark', 'ocean-light',
|
|
36
|
+
'forest-dark', 'forest-light',
|
|
37
|
+
'sunset-dark', 'sunset-light',
|
|
38
|
+
'royal-dark', 'royal-light',
|
|
39
|
+
'lemon-dark', 'lemon-light',
|
|
40
|
+
'silver-dark', 'silver-light',
|
|
41
|
+
'grayve',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Utilities
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function section(title) {
|
|
49
|
+
console.log(`\n ${title}`);
|
|
50
|
+
console.log(` ${'─'.repeat(title.length)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read password from stdin with character masking (*). */
|
|
54
|
+
function readPassword(label) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
process.stdout.write(` ${label}: `);
|
|
57
|
+
const chars = [];
|
|
58
|
+
|
|
59
|
+
process.stdin.setRawMode(true);
|
|
60
|
+
process.stdin.setEncoding('utf8');
|
|
61
|
+
process.stdin.resume();
|
|
62
|
+
|
|
63
|
+
const handler = (key) => {
|
|
64
|
+
if (key === '\r' || key === '\n') {
|
|
65
|
+
process.stdin.setRawMode(false);
|
|
66
|
+
process.stdin.pause();
|
|
67
|
+
process.stdin.off('data', handler);
|
|
68
|
+
process.stdout.write('\n');
|
|
69
|
+
resolve(chars.join(''));
|
|
70
|
+
} else if (key === '\u007F' || key === '\b') {
|
|
71
|
+
if (chars.length > 0) {
|
|
72
|
+
chars.pop();
|
|
73
|
+
process.stdout.write('\b \b');
|
|
74
|
+
}
|
|
75
|
+
} else if (key === '\u0003') {
|
|
76
|
+
process.stdout.write('\n');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
} else {
|
|
79
|
+
chars.push(key);
|
|
80
|
+
process.stdout.write('*');
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
process.stdin.on('data', handler);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read the current .env as a key→value map. */
|
|
89
|
+
async function readEnv() {
|
|
90
|
+
if (!existsSync(ENV_FILE)) return {};
|
|
91
|
+
const lines = (await readFile(ENV_FILE, 'utf8')).split('\n');
|
|
92
|
+
return Object.fromEntries(
|
|
93
|
+
lines
|
|
94
|
+
.filter(l => l.includes('=') && !l.trimStart().startsWith('#'))
|
|
95
|
+
.map(l => {
|
|
96
|
+
const idx = l.indexOf('=');
|
|
97
|
+
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()];
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Write an env map back to .env (preserves order). */
|
|
103
|
+
async function writeEnv(env) {
|
|
104
|
+
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
105
|
+
await writeFile(ENV_FILE, lines.join('\n') + '\n', 'utf8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Count .json files in the users directory. */
|
|
109
|
+
async function countUsers() {
|
|
110
|
+
try {
|
|
111
|
+
const files = await readdir(USERS_DIR);
|
|
112
|
+
return files.filter(f => f.endsWith('.json')).length;
|
|
113
|
+
} catch {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Main
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
console.log('');
|
|
123
|
+
console.log(' ┌────────────────────────────┐');
|
|
124
|
+
console.log(' │ Domma CMS Setup Wizard │');
|
|
125
|
+
console.log(' └────────────────────────────┘');
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Step 1 — JWT Secret
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
section('1. JWT Secret');
|
|
131
|
+
|
|
132
|
+
const env = await readEnv();
|
|
133
|
+
const existingSecret = env.JWT_SECRET ?? '';
|
|
134
|
+
const isPlaceholder = !existingSecret
|
|
135
|
+
|| existingSecret.toLowerCase().startsWith('change')
|
|
136
|
+
|| existingSecret.length < 32;
|
|
137
|
+
|
|
138
|
+
if (!isPlaceholder) {
|
|
139
|
+
console.log(' ✓ JWT_SECRET already configured — skipping.');
|
|
140
|
+
} else {
|
|
141
|
+
const secret = randomBytes(48).toString('hex');
|
|
142
|
+
env.JWT_SECRET = secret;
|
|
143
|
+
if (!env.NODE_ENV) env.NODE_ENV = 'development';
|
|
144
|
+
await writeEnv(env);
|
|
145
|
+
console.log(' ✓ Secure JWT_SECRET generated and written to .env');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Step 2 — Admin Account
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
section('2. Admin Account');
|
|
152
|
+
|
|
153
|
+
const userCount = await countUsers();
|
|
154
|
+
if (userCount > 0) {
|
|
155
|
+
console.log(` ✓ ${userCount} user(s) already exist — skipping admin creation.`);
|
|
156
|
+
} else {
|
|
157
|
+
console.log(' No users found. Create the first admin account.\n');
|
|
158
|
+
|
|
159
|
+
let rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
160
|
+
const name = (await rl.question(' Full name : ')).trim();
|
|
161
|
+
const email = (await rl.question(' Email address : ')).trim();
|
|
162
|
+
rl.close();
|
|
163
|
+
|
|
164
|
+
const password = await readPassword('Password (min. 8 chars)');
|
|
165
|
+
|
|
166
|
+
if (!name || !email || !password) {
|
|
167
|
+
console.error('\n Error: all fields are required.\n');
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
if (password.length < 8) {
|
|
171
|
+
console.error('\n Error: password must be at least 8 characters.\n');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
process.stdout.write('\n Hashing password…');
|
|
176
|
+
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
177
|
+
const now = new Date().toISOString();
|
|
178
|
+
|
|
179
|
+
const user = {
|
|
180
|
+
id: uuidv4(),
|
|
181
|
+
email: email.toLowerCase(),
|
|
182
|
+
name,
|
|
183
|
+
password: hash,
|
|
184
|
+
role: 'admin',
|
|
185
|
+
isActive: true,
|
|
186
|
+
createdAt: now,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
lastLogin: null
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
await mkdir(USERS_DIR, { recursive: true });
|
|
192
|
+
await writeFile(
|
|
193
|
+
path.join(USERS_DIR, `${user.id}.json`),
|
|
194
|
+
JSON.stringify(user, null, 2) + '\n',
|
|
195
|
+
'utf8'
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
console.log(` done.\n ✓ Admin account created for ${email}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Step 3 — Site Identity
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
section('3. Site Identity');
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
208
|
+
|
|
209
|
+
const siteTitle = (await rl.question(' Site title : ')).trim();
|
|
210
|
+
const tagline = (await rl.question(' Tagline : ')).trim();
|
|
211
|
+
|
|
212
|
+
rl.close();
|
|
213
|
+
|
|
214
|
+
const site = JSON.parse(await readFile(SITE_CONFIG, 'utf8'));
|
|
215
|
+
if (siteTitle) site.title = siteTitle;
|
|
216
|
+
if (tagline) site.tagline = tagline;
|
|
217
|
+
site.seo = site.seo ?? {};
|
|
218
|
+
if (siteTitle) site.seo.defaultTitle = siteTitle;
|
|
219
|
+
await writeFile(SITE_CONFIG, JSON.stringify(site, null, 4) + '\n', 'utf8');
|
|
220
|
+
|
|
221
|
+
const nav = JSON.parse(await readFile(NAV_CONFIG, 'utf8'));
|
|
222
|
+
nav.brand = nav.brand ?? {};
|
|
223
|
+
if (siteTitle) nav.brand.text = siteTitle;
|
|
224
|
+
await writeFile(NAV_CONFIG, JSON.stringify(nav, null, 4) + '\n', 'utf8');
|
|
225
|
+
|
|
226
|
+
console.log(' ✓ Updated config/site.json and config/navigation.json');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Step 4 — Theme
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
section('4. Theme');
|
|
233
|
+
|
|
234
|
+
THEMES.forEach((t, i) => {
|
|
235
|
+
const num = String(i + 1).padStart(2);
|
|
236
|
+
process.stdout.write(` [${num}] ${t.padEnd(18)}`);
|
|
237
|
+
if ((i + 1) % 3 === 0 || i === THEMES.length - 1) process.stdout.write('\n');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
242
|
+
const choice = (await rl.question(`\n Choice (1–${THEMES.length}, Enter = charcoal-dark): `)).trim();
|
|
243
|
+
rl.close();
|
|
244
|
+
|
|
245
|
+
const idx = parseInt(choice, 10) - 1;
|
|
246
|
+
const theme = (idx >= 0 && idx < THEMES.length) ? THEMES[idx] : 'charcoal-dark';
|
|
247
|
+
|
|
248
|
+
const site = JSON.parse(await readFile(SITE_CONFIG, 'utf8'));
|
|
249
|
+
site.theme = theme;
|
|
250
|
+
await writeFile(SITE_CONFIG, JSON.stringify(site, null, 4) + '\n', 'utf8');
|
|
251
|
+
|
|
252
|
+
console.log(` ✓ Theme set to "${theme}"`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Done
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
console.log('');
|
|
259
|
+
console.log(' ══════════════════════════════════════');
|
|
260
|
+
console.log(' Setup complete! Start the server with:');
|
|
261
|
+
console.log(' npm start');
|
|
262
|
+
console.log(' ══════════════════════════════════════');
|
|
263
|
+
console.log('');
|
package/server/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Loader
|
|
3
|
+
* Loads configuration from config/ JSON files.
|
|
4
|
+
*
|
|
5
|
+
* Structural config (server, auth, content) is loaded once at startup.
|
|
6
|
+
* Admin-editable config (site, navigation, presets, plugins) uses
|
|
7
|
+
* getConfig()/saveConfig() to read/write fresh from disk on each call.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = path.resolve('config');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Synchronously load and parse a JSON config file.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} name - File name without extension
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
function loadJson(name) {
|
|
21
|
+
return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, `${name}.json`), 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a config JSON file from disk (always fresh).
|
|
26
|
+
*
|
|
27
|
+
* @param {string} name - File name without extension
|
|
28
|
+
* @returns {object}
|
|
29
|
+
*/
|
|
30
|
+
export function getConfig(name) {
|
|
31
|
+
return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, `${name}.json`), 'utf8'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write a config JSON file to disk.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} name - File name without extension
|
|
38
|
+
* @param {object} data
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
export function saveConfig(name, data) {
|
|
42
|
+
fs.writeFileSync(path.join(CONFIG_DIR, `${name}.json`), JSON.stringify(data, null, 2) + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Structural config — loaded once at startup.
|
|
47
|
+
* Do not use for admin-editable settings (site, navigation, presets, plugins).
|
|
48
|
+
*/
|
|
49
|
+
const serverConfig = loadJson('server');
|
|
50
|
+
if (process.env.PORT) serverConfig.port = parseInt(process.env.PORT, 10);
|
|
51
|
+
|
|
52
|
+
export const config = {
|
|
53
|
+
server: serverConfig,
|
|
54
|
+
auth: loadJson('auth'),
|
|
55
|
+
content: loadJson('content')
|
|
56
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Middleware
|
|
3
|
+
* JWT-based authentication with role guards for Domma CMS.
|
|
4
|
+
*/
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
|
|
7
|
+
const { roles } = config.auth;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Role hierarchy ordered from most to least privileged.
|
|
11
|
+
* Used by canManageUser() to compare privilege levels.
|
|
12
|
+
*/
|
|
13
|
+
export const ROLE_HIERARCHY = Object.entries(roles)
|
|
14
|
+
.sort((a, b) => a[1].level - b[1].level)
|
|
15
|
+
.map(([key]) => key);
|
|
16
|
+
|
|
17
|
+
export const ROLES = Object.fromEntries(
|
|
18
|
+
Object.entries(roles).map(([key]) => [key.toUpperCase(), key])
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Verify JWT Bearer token. Populates request.user on success.
|
|
23
|
+
*
|
|
24
|
+
* @param {FastifyRequest} request
|
|
25
|
+
* @param {FastifyReply} reply
|
|
26
|
+
* @returns {Promise<void>}
|
|
27
|
+
*/
|
|
28
|
+
export async function authenticate(request, reply) {
|
|
29
|
+
try {
|
|
30
|
+
await request.jwtVerify();
|
|
31
|
+
} catch {
|
|
32
|
+
return reply.code(401).send({
|
|
33
|
+
statusCode: 401,
|
|
34
|
+
error: 'Unauthorised',
|
|
35
|
+
message: 'Invalid or missing authentication token'
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return a preHandler that enforces one of the specified roles.
|
|
42
|
+
* Must be used after authenticate.
|
|
43
|
+
*
|
|
44
|
+
* @param {string[]} allowedRoles
|
|
45
|
+
* @returns {Function}
|
|
46
|
+
*/
|
|
47
|
+
export function requireRole(allowedRoles) {
|
|
48
|
+
const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
|
|
49
|
+
|
|
50
|
+
return async (request, reply) => {
|
|
51
|
+
if (!request.user) {
|
|
52
|
+
return reply.code(401).send({
|
|
53
|
+
statusCode: 401,
|
|
54
|
+
error: 'Unauthorised',
|
|
55
|
+
message: 'Authentication required'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!allowed.includes(request.user.role)) {
|
|
60
|
+
return reply.code(403).send({
|
|
61
|
+
statusCode: 403,
|
|
62
|
+
error: 'Forbidden',
|
|
63
|
+
message: `Access denied. Required role: ${allowed.join(' or ')}`
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shorthand preHandler — admin only.
|
|
71
|
+
*
|
|
72
|
+
* @param {FastifyRequest} request
|
|
73
|
+
* @param {FastifyReply} reply
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
export async function requireAdmin(request, reply) {
|
|
77
|
+
if (!request.user) {
|
|
78
|
+
return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
|
|
79
|
+
}
|
|
80
|
+
if (request.user.role !== 'admin') {
|
|
81
|
+
return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Determine whether an actor can manage a target user.
|
|
87
|
+
* Managers cannot create, edit, or delete admins.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} actorRole - Role of the user performing the action
|
|
90
|
+
* @param {string} targetRole - Role of the user being acted upon
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function canManageUser(actorRole, targetRole) {
|
|
94
|
+
const actorLevel = roles[actorRole]?.level ?? Infinity;
|
|
95
|
+
const targetLevel = roles[targetRole]?.level ?? Infinity;
|
|
96
|
+
return actorLevel < targetLevel;
|
|
97
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth API
|
|
3
|
+
* GET /api/auth/setup-status - check if any users exist
|
|
4
|
+
* POST /api/auth/setup - create initial admin (only when 0 users)
|
|
5
|
+
* POST /api/auth/login - { email, password } → { token, refreshToken, user }
|
|
6
|
+
* GET /api/auth/me - return current user from token
|
|
7
|
+
* POST /api/auth/refresh - { refreshToken } → { token }
|
|
8
|
+
*/
|
|
9
|
+
import { config } from '../../config.js';
|
|
10
|
+
import { authenticate } from '../../middleware/auth.js';
|
|
11
|
+
import {
|
|
12
|
+
countUsers,
|
|
13
|
+
createUser,
|
|
14
|
+
getUserByEmail,
|
|
15
|
+
getUserById,
|
|
16
|
+
touchLastLogin,
|
|
17
|
+
validatePassword
|
|
18
|
+
} from '../../services/users.js';
|
|
19
|
+
|
|
20
|
+
const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
|
|
21
|
+
|
|
22
|
+
export async function authRoutes(fastify) {
|
|
23
|
+
// GET /api/auth/setup-status
|
|
24
|
+
fastify.get('/auth/setup-status', async () => {
|
|
25
|
+
const count = await countUsers();
|
|
26
|
+
return { needsSetup: count === 0 };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// POST /api/auth/setup — create the very first admin (only allowed when no users exist)
|
|
30
|
+
fastify.post('/auth/setup', async (request, reply) => {
|
|
31
|
+
const count = await countUsers();
|
|
32
|
+
if (count > 0) {
|
|
33
|
+
return reply.status(403).send({ error: 'Setup already complete' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { name, email, password } = request.body || {};
|
|
37
|
+
if (!name || !email || !password) {
|
|
38
|
+
return reply.status(400).send({ error: 'name, email and password are required' });
|
|
39
|
+
}
|
|
40
|
+
if (password.length < 8) {
|
|
41
|
+
return reply.status(400).send({ error: 'Password must be at least 8 characters' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const user = await createUser({ name, email, password, role: 'admin' });
|
|
45
|
+
const { token, refreshToken } = signTokens(fastify, user);
|
|
46
|
+
return reply.status(201).send({ token, refreshToken, user });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// POST /api/auth/login
|
|
50
|
+
fastify.post('/auth/login', async (request, reply) => {
|
|
51
|
+
const { email, password } = request.body || {};
|
|
52
|
+
if (!email || !password) {
|
|
53
|
+
return reply.status(400).send({ error: 'email and password are required' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const user = await getUserByEmail(email);
|
|
57
|
+
if (!user || !user.isActive) {
|
|
58
|
+
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const valid = await validatePassword(password, user.password);
|
|
62
|
+
if (!valid) {
|
|
63
|
+
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await touchLastLogin(user.id);
|
|
67
|
+
|
|
68
|
+
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
69
|
+
const { token, refreshToken } = signTokens(fastify, safeUser);
|
|
70
|
+
return { token, refreshToken, user: safeUser };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// GET /api/auth/me
|
|
74
|
+
fastify.get('/auth/me', { preHandler: [authenticate] }, async (request, reply) => {
|
|
75
|
+
const user = await getUserById(request.user.id);
|
|
76
|
+
if (!user) return reply.status(404).send({ error: 'User not found' });
|
|
77
|
+
return user;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// POST /api/auth/refresh
|
|
81
|
+
fastify.post('/auth/refresh', async (request, reply) => {
|
|
82
|
+
const { refreshToken } = request.body || {};
|
|
83
|
+
if (!refreshToken) {
|
|
84
|
+
return reply.status(400).send({ error: 'refreshToken is required' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = fastify.jwt.verify(refreshToken);
|
|
90
|
+
} catch {
|
|
91
|
+
return reply.status(401).send({ error: 'Invalid or expired refresh token' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const user = await getUserById(payload.id);
|
|
95
|
+
if (!user || !user.isActive) {
|
|
96
|
+
return reply.status(401).send({ error: 'User not found or inactive' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
100
|
+
const token = fastify.jwt.sign(safeUser, { expiresIn: accessTokenExpiry });
|
|
101
|
+
return { token };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sign both access and refresh tokens for a user payload.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} fastify
|
|
109
|
+
* @param {object} payload
|
|
110
|
+
* @returns {{ token: string, refreshToken: string }}
|
|
111
|
+
*/
|
|
112
|
+
function signTokens(fastify, payload) {
|
|
113
|
+
const token = fastify.jwt.sign(payload, { expiresIn: accessTokenExpiry });
|
|
114
|
+
const refreshToken = fastify.jwt.sign(payload, { expiresIn: refreshTokenExpiry });
|
|
115
|
+
return { token, refreshToken };
|
|
116
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layouts (Presets) API
|
|
3
|
+
* GET /api/layouts - get all layout presets
|
|
4
|
+
* PUT /api/layouts - save layout presets
|
|
5
|
+
*/
|
|
6
|
+
import { getConfig, saveConfig } from '../../config.js';
|
|
7
|
+
import { authenticate, requireRole } from '../../middleware/auth.js';
|
|
8
|
+
import { config } from '../../config.js';
|
|
9
|
+
|
|
10
|
+
export async function layoutsRoutes(fastify) {
|
|
11
|
+
const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.layouts)] };
|
|
12
|
+
|
|
13
|
+
fastify.get('/layouts', guard, async () => {
|
|
14
|
+
return getConfig('presets');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
fastify.put('/layouts', guard, async (request, reply) => {
|
|
18
|
+
const data = request.body;
|
|
19
|
+
if (!data || typeof data !== 'object') {
|
|
20
|
+
return reply.status(400).send({ error: 'Invalid presets data' });
|
|
21
|
+
}
|
|
22
|
+
saveConfig('presets', data);
|
|
23
|
+
return { success: true };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media API
|
|
3
|
+
* GET /api/media - list media files
|
|
4
|
+
* POST /api/media - upload a file
|
|
5
|
+
* DELETE /api/media/:name - delete a file
|
|
6
|
+
*/
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
|
|
9
|
+
import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
|
|
10
|
+
import {authenticate, requireRole} from '../../middleware/auth.js';
|
|
11
|
+
import {config} from '../../config.js';
|
|
12
|
+
|
|
13
|
+
// Safe filename: strip path traversal and restrict to alphanumeric + safe chars
|
|
14
|
+
function sanitiseFilename(name) {
|
|
15
|
+
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function mediaRoutes(fastify) {
|
|
19
|
+
const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.media)] };
|
|
20
|
+
|
|
21
|
+
fastify.get('/media', guard, async () => {
|
|
22
|
+
return listMedia();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
fastify.post('/media', guard, async (request, reply) => {
|
|
26
|
+
const results = [];
|
|
27
|
+
for await (const data of request.files()) {
|
|
28
|
+
const filename = sanitiseFilename(data.filename);
|
|
29
|
+
const chunks = [];
|
|
30
|
+
for await (const chunk of data.file) {
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
}
|
|
33
|
+
results.push(await saveMedia(filename, Buffer.concat(chunks)));
|
|
34
|
+
}
|
|
35
|
+
if (!results.length) return reply.status(400).send({error: 'No file uploaded'});
|
|
36
|
+
return reply.status(201).send(results.length === 1 ? results[0] : results);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
fastify.patch('/media/:name', guard, async (request, reply) => {
|
|
40
|
+
const oldName = sanitiseFilename(request.params.name);
|
|
41
|
+
const newName = sanitiseFilename(request.body?.newName ?? '');
|
|
42
|
+
if (!newName) return reply.status(400).send({error: 'newName is required.'});
|
|
43
|
+
if (oldName === newName) return reply.status(400).send({error: 'New name is the same as the current name.'});
|
|
44
|
+
try {
|
|
45
|
+
return await renameMedia(oldName, newName);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return reply.status(409).send({error: err.message});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
fastify.delete('/media/:name', guard, async (request, reply) => {
|
|
52
|
+
const name = sanitiseFilename(request.params.name);
|
|
53
|
+
await deleteMedia(name);
|
|
54
|
+
return { success: true };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
fastify.get('/media/:name/info', guard, async (request, reply) => {
|
|
58
|
+
const name = sanitiseFilename(request.params.name);
|
|
59
|
+
if (!isEditableImage(name)) {
|
|
60
|
+
return reply.status(400).send({error: 'Not an editable image format'});
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return await getImageInfo(name);
|
|
64
|
+
} catch {
|
|
65
|
+
return reply.status(404).send({error: 'File not found'});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
fastify.post('/media/:name/transform', guard, async (request, reply) => {
|
|
70
|
+
const name = sanitiseFilename(request.params.name);
|
|
71
|
+
if (!isEditableImage(name)) {
|
|
72
|
+
return reply.status(400).send({error: 'Not an editable image format'});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const {operations = {}, saveAs} = request.body ?? {};
|
|
76
|
+
|
|
77
|
+
// Sanitise fields that reference filesystem paths
|
|
78
|
+
if (operations.watermark?.image) {
|
|
79
|
+
operations.watermark.image = sanitiseFilename(operations.watermark.image);
|
|
80
|
+
}
|
|
81
|
+
if (operations._deleteOriginal) {
|
|
82
|
+
operations._deleteOriginal = sanitiseFilename(operations._deleteOriginal);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const outputFilename = saveAs ? sanitiseFilename(saveAs) : null;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
return await transformImage(name, operations, outputFilename);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return reply.status(500).send({error: err.message});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation API
|
|
3
|
+
* GET /api/navigation - get navigation config
|
|
4
|
+
* PUT /api/navigation - save navigation config
|
|
5
|
+
*/
|
|
6
|
+
import { getConfig, saveConfig } from '../../config.js';
|
|
7
|
+
import { authenticate, requireRole } from '../../middleware/auth.js';
|
|
8
|
+
import { config } from '../../config.js';
|
|
9
|
+
|
|
10
|
+
export async function navigationRoutes(fastify) {
|
|
11
|
+
const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.navigation)] };
|
|
12
|
+
|
|
13
|
+
fastify.get('/navigation', guard, async () => {
|
|
14
|
+
return getConfig('navigation');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
fastify.put('/navigation', guard, async (request, reply) => {
|
|
18
|
+
const data = request.body;
|
|
19
|
+
if (!data || typeof data !== 'object') {
|
|
20
|
+
return reply.status(400).send({ error: 'Invalid navigation data' });
|
|
21
|
+
}
|
|
22
|
+
// Normalise child key: Domma navbar expects `items`, not `children`
|
|
23
|
+
if (Array.isArray(data.items)) {
|
|
24
|
+
data.items = data.items.map(item => {
|
|
25
|
+
const children = item.items || item.children;
|
|
26
|
+
if (children?.length) {
|
|
27
|
+
const { children: _c, ...rest } = item;
|
|
28
|
+
return { ...rest, items: children };
|
|
29
|
+
}
|
|
30
|
+
const { children: _c, ...rest } = item;
|
|
31
|
+
return rest;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
saveConfig('navigation', data);
|
|
35
|
+
return { success: true };
|
|
36
|
+
});
|
|
37
|
+
}
|