radix-cms 0.1.0 → 1.0.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 (59) hide show
  1. package/.env.example +17 -0
  2. package/CHANGELOG.md +10 -0
  3. package/CONTRIBUTING.md +40 -0
  4. package/LICENSE +197 -0
  5. package/NOTICE +3 -0
  6. package/README.md +126 -56
  7. package/SECURITY.md +22 -0
  8. package/dist/app.js +80 -0
  9. package/dist/config/db.js +66 -0
  10. package/dist/init/dbInit.js +132 -0
  11. package/dist/init/dbInit2.js +234 -0
  12. package/dist/middleware/auth.js +48 -0
  13. package/dist/plugins/my-seo-plugin/index.js +29 -0
  14. package/dist/routes/admin.js +808 -0
  15. package/dist/routes/index.js +218 -0
  16. package/dist/sql/radix.sql +118 -0
  17. package/dist/types/plugin.js +10 -0
  18. package/dist/utils/PluginManager.js +112 -0
  19. package/dist/utils/settings.js +80 -0
  20. package/dist/utils/themeScanner.js +33 -0
  21. package/dist/utils/versionCheck.js +74 -0
  22. package/docs/CMS_CODE_GUIDE.md +372 -0
  23. package/docs/CMS_USER_GUIDE.md +344 -0
  24. package/docs/Publishing Guidelines.txt +14 -0
  25. package/package.json +62 -6
  26. package/public/css/admin.css +178 -0
  27. package/public/css/fallback.css +53 -0
  28. package/public/hello.html +1 -0
  29. package/views/admin/create-page.ejs +82 -0
  30. package/views/admin/dashboard.ejs +44 -0
  31. package/views/admin/edit-page.ejs +87 -0
  32. package/views/admin/edit-user.ejs +45 -0
  33. package/views/admin/login.ejs +39 -0
  34. package/views/admin/media.ejs +79 -0
  35. package/views/admin/pages.ejs +82 -0
  36. package/views/admin/partials/footer.ejs +4 -0
  37. package/views/admin/partials/header.ejs +57 -0
  38. package/views/admin/settings.ejs +91 -0
  39. package/views/admin/setup-admin.ejs +69 -0
  40. package/views/admin/setup-db.ejs +112 -0
  41. package/views/admin/themes.ejs +24 -0
  42. package/views/admin/updates.ejs +79 -0
  43. package/views/admin/users.ejs +107 -0
  44. package/views/defaults/404.ejs +20 -0
  45. package/views/defaults/500.ejs +20 -0
  46. package/views/layouts/admin-layout.ejs +18 -0
  47. package/views/pages/gallery.ejs +5 -0
  48. package/views/pages/hello.ejs +4 -0
  49. package/views/pages/services.ejs +3 -0
  50. package/views/themes/default/assets/css/style.css +199 -0
  51. package/views/themes/default/assets/js/main.js +8 -0
  52. package/views/themes/default/index.ejs +55 -0
  53. package/views/themes/landing/assets/css/style.css +6 -0
  54. package/views/themes/landing/assets/js/main.js +8 -0
  55. package/views/themes/landing/index.ejs +26 -0
  56. package/views/themes/test/default.ejs +0 -0
  57. package/views/themes/test/full-width.ejs +0 -0
  58. package/views/themes/test/landing-page.ejs +0 -0
  59. package/index.js +0 -15
@@ -0,0 +1,808 @@
1
+ "use strict";
2
+ /**************************************************************************************************
3
+ * RadixCMS
4
+ *
5
+ * DESCRIPTION: administration routes, setup, content, users, media, and settings.
6
+ *
7
+ * Copyright (C) 2026 Ultra Spark Software <salve@ultraspark.net>
8
+ * SPDX-License-Identifier: GPL-3.0-or-later
9
+ **************************************************************************************************/
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ const express_1 = require("express");
15
+ const bcrypt_1 = __importDefault(require("bcrypt"));
16
+ const path_1 = __importDefault(require("path"));
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const multer_1 = __importDefault(require("multer"));
19
+ const db_1 = __importDefault(require("../config/db"));
20
+ const themeScanner_1 = require("../utils/themeScanner");
21
+ const dotenv_1 = __importDefault(require("dotenv"));
22
+ const promise_1 = __importDefault(require("mysql2/promise"));
23
+ const dbInit2_1 = require("../init/dbInit2");
24
+ const nodemailer_1 = __importDefault(require("nodemailer"));
25
+ const versionCheck_1 = require("../utils/versionCheck");
26
+ const adm_zip_1 = __importDefault(require("adm-zip"));
27
+ const router = (0, express_1.Router)();
28
+ function updateEnvVersion(version) {
29
+ if (!version)
30
+ return;
31
+ const envPath = path_1.default.join(process.cwd(), '.env');
32
+ let envContent = fs_1.default.existsSync(envPath) ? fs_1.default.readFileSync(envPath, 'utf8') : '';
33
+ const re = /^APP_VERSION=.*$/m;
34
+ if (re.test(envContent)) {
35
+ envContent = envContent.replace(re, `APP_VERSION=${version}`);
36
+ }
37
+ else {
38
+ envContent += (envContent ? '\n' : '') + `APP_VERSION=${version}`;
39
+ }
40
+ fs_1.default.writeFileSync(envPath, envContent, { encoding: 'utf8' });
41
+ process.env.APP_VERSION = version;
42
+ }
43
+ // Installation check: if database connection info is missing or the DB is not reachable,
44
+ // route the user into the installer workflow.
45
+ router.use(async (req, res, next) => {
46
+ if (req.path === '/install' || req.path.startsWith('/setup'))
47
+ return next();
48
+ const hasDbConfig = !!(process.env.DB_HOST &&
49
+ process.env.DB_USER &&
50
+ process.env.DB_PASSWORD !== undefined &&
51
+ process.env.DB_NAME);
52
+ if (!hasDbConfig) {
53
+ console.warn('[Radix Setup]: Missing database configuration, redirecting to installer.');
54
+ return res.redirect('/admin/install');
55
+ }
56
+ try {
57
+ // quick probe against the configured pool to determine if DB is working
58
+ await db_1.default.execute('SELECT 1');
59
+ return next();
60
+ }
61
+ catch (err) {
62
+ console.warn('[Radix Setup]: Database not reachable, redirecting to installer.');
63
+ return res.redirect('/admin/install');
64
+ }
65
+ });
66
+ // --- Install / Setup: Database Connection Form ---
67
+ router.get('/install', (_req, res) => {
68
+ res.render('admin/setup-db', { error: null, values: {} });
69
+ });
70
+ router.get('/setup/db', (_req, res) => {
71
+ res.render('admin/setup-db', { error: null, values: {} });
72
+ });
73
+ // POST /admin/setup/db/test - Test DB connection using provided form values without saving to .env
74
+ router.post('/setup/db/test', async (req, res) => {
75
+ const { dbName, dbUser, dbPass, dbHost, dbPort } = req.body;
76
+ const cfg = {
77
+ host: dbHost || '127.0.0.1',
78
+ user: dbUser || undefined,
79
+ password: dbPass || undefined,
80
+ port: Number(dbPort) || 3306,
81
+ connectTimeout: 3000
82
+ };
83
+ let conn;
84
+ try {
85
+ conn = await promise_1.default.createConnection(cfg);
86
+ // if a database name is provided, ensure it exists (querying INFORMATION_SCHEMA) otherwise run simple probe
87
+ if (dbName) {
88
+ const [rows] = await conn.query('SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?', [dbName]);
89
+ if (!rows || (Array.isArray(rows) && rows.length === 0)) {
90
+ await conn.end();
91
+ return res.status(400).json({ ok: false, error: `Database '${dbName}' not found or inaccessible.` });
92
+ }
93
+ }
94
+ else {
95
+ await conn.query('SELECT 1');
96
+ }
97
+ await conn.end();
98
+ return res.json({ ok: true, message: 'Connection successful' });
99
+ }
100
+ catch (err) {
101
+ try {
102
+ if (conn)
103
+ await conn.end();
104
+ }
105
+ catch (e) { /* ignore */ }
106
+ return res.status(400).json({ ok: false, error: err.message || String(err) });
107
+ }
108
+ });
109
+ router.post('/setup/db', async (req, res) => {
110
+ const { dbName, dbUser, dbPass, dbHost, dbPort, tablePrefix } = req.body;
111
+ const envPath = path_1.default.join(__dirname, '../../.env');
112
+ try {
113
+ // Read existing .env (if any)
114
+ let envContent = '';
115
+ if (fs_1.default.existsSync(envPath))
116
+ envContent = fs_1.default.readFileSync(envPath, 'utf8');
117
+ const setEnv = (key, value) => {
118
+ const re = new RegExp(`^${key}=.*$`, 'm');
119
+ if (re.test(envContent)) {
120
+ envContent = envContent.replace(re, `${key}=${value}`);
121
+ }
122
+ else {
123
+ envContent += (envContent ? '\n' : '') + `${key}=${value}`;
124
+ }
125
+ };
126
+ setEnv('DB_NAME', dbName || 'radix');
127
+ setEnv('DB_USER', dbUser || '');
128
+ setEnv('DB_PASSWORD', dbPass || '');
129
+ setEnv('DB_HOST', dbHost || '127.0.0.1');
130
+ if (dbPort)
131
+ setEnv('DB_PORT', dbPort);
132
+ if (tablePrefix)
133
+ setEnv('TABLE_PREFIX', tablePrefix);
134
+ fs_1.default.writeFileSync(envPath, envContent, { encoding: 'utf8' });
135
+ // reload env and try to initialize the DB schema — explicitly override process.env with values from the new .env
136
+ try {
137
+ const parsedEnv = dotenv_1.default.parse(fs_1.default.readFileSync(envPath, 'utf8'));
138
+ for (const k of Object.keys(parsedEnv)) {
139
+ process.env[k] = parsedEnv[k];
140
+ }
141
+ }
142
+ catch (e) {
143
+ // fall back to regular dotenv config if parsing fails
144
+ dotenv_1.default.config({ path: envPath });
145
+ }
146
+ await (0, dbInit2_1.initDatabase)();
147
+ return res.redirect('/admin/setup/admin');
148
+ }
149
+ catch (err) {
150
+ console.error('[Radix Setup DB Error]:', err);
151
+ return res.render('admin/setup-db', { error: String(err), values: req.body });
152
+ }
153
+ });
154
+ // --- Setup: Admin / Site Info Form ---
155
+ router.get('/setup/admin', (_req, res) => {
156
+ res.render('admin/setup-admin', { error: null, values: {} });
157
+ });
158
+ router.post('/setup/admin', async (req, res) => {
159
+ const { siteTitle, username, password, email, searchEngineVisibility } = req.body;
160
+ try {
161
+ await (0, dbInit2_1.initDatabase)();
162
+ const passwordHash = await bcrypt_1.default.hash(password, 10);
163
+ // create administrator user
164
+ await db_1.default.execute('INSERT INTO users (username, password, password_hash, email, role, created_at) VALUES (?, ?, ?, ?, ?, NOW())', [username, null, passwordHash, email, 'Administrator']);
165
+ // save site settings
166
+ await db_1.default.execute('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)', ['site_title', siteTitle || 'Radix Site']);
167
+ await db_1.default.execute('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)', ['search_engine_visibility', searchEngineVisibility ? '1' : '0']);
168
+ return res.redirect('/admin/login');
169
+ }
170
+ catch (err) {
171
+ console.error('[Radix Setup Admin Error]:', err);
172
+ return res.render('admin/setup-admin', { error: String(err), values: req.body });
173
+ }
174
+ });
175
+ // ==========================================
176
+ // 1. MIDDLEWARE & HELPER CONFIGURATION
177
+ // ==========================================
178
+ const ROLE_HIERARCHY = {
179
+ 'Administrator': 5,
180
+ 'Manager': 4,
181
+ 'Editor': 3,
182
+ 'Registered': 2,
183
+ 'Unregistered': 1
184
+ };
185
+ // GET /admin/login - Render Login View
186
+ router.get('/login', (req, res) => {
187
+ res.render('admin/login', { error: null });
188
+ });
189
+ // POST /admin/login - Process Login Form
190
+ router.post('/login', async (req, res) => {
191
+ const { username, password } = req.body;
192
+ try {
193
+ const [rows] = await db_1.default.execute('SELECT * FROM users WHERE username = ? OR email = ?', [username, username]);
194
+ if (rows.length === 0) {
195
+ res.render('admin/login', { error: 'Invalid username or password.' });
196
+ return;
197
+ }
198
+ const user = rows[0];
199
+ const match = await bcrypt_1.default.compare(password, user.password_hash);
200
+ if (!match) {
201
+ res.render('admin/login', { error: 'Invalid username or password.' });
202
+ return;
203
+ }
204
+ if (user.role === 'Unregistered') {
205
+ res.render('admin/login', { error: 'This account is not permitted to log in.' });
206
+ return;
207
+ }
208
+ req.session.user = {
209
+ id: user.id,
210
+ username: user.username,
211
+ email: user.email,
212
+ role: user.role
213
+ };
214
+ if (user.role === 'Registered') {
215
+ res.redirect('/');
216
+ return;
217
+ }
218
+ res.redirect('/admin');
219
+ }
220
+ catch (err) {
221
+ console.error('[Radix Login Error]:', err);
222
+ res.status(500).render('defaults/500');
223
+ }
224
+ });
225
+ // GET /admin/logout - Destroy Session
226
+ router.get('/logout', (req, res) => {
227
+ req.session.destroy(() => {
228
+ res.redirect('/admin/login');
229
+ });
230
+ });
231
+ // RBAC Middleware Guard
232
+ function requireRole(minRole) {
233
+ return (req, res, next) => {
234
+ const user = req.session?.user;
235
+ if (!user) {
236
+ res.redirect('/admin/login');
237
+ return;
238
+ }
239
+ const userLevel = ROLE_HIERARCHY[user.role] || 0;
240
+ const requiredLevel = ROLE_HIERARCHY[minRole] || 5;
241
+ if (userLevel < requiredLevel) {
242
+ res.status(403).render('defaults/403', { message: 'Access Denied: Insufficient Role Privileges.' });
243
+ return;
244
+ }
245
+ next();
246
+ };
247
+ }
248
+ // Multer Storage Setup
249
+ const UPLOAD_DIR = path_1.default.join(__dirname, '../../public/uploads');
250
+ if (!fs_1.default.existsSync(UPLOAD_DIR)) {
251
+ fs_1.default.mkdirSync(UPLOAD_DIR, { recursive: true });
252
+ }
253
+ const storage = multer_1.default.diskStorage({
254
+ destination: (_req, _file, cb) => {
255
+ cb(null, UPLOAD_DIR);
256
+ },
257
+ filename: (_req, file, cb) => {
258
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
259
+ const ext = path_1.default.extname(file.originalname);
260
+ cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
261
+ }
262
+ });
263
+ const upload = (0, multer_1.default)({
264
+ storage,
265
+ limits: { fileSize: 10 * 1024 * 1024 },
266
+ fileFilter: (_req, file, cb) => {
267
+ const allowedTypes = /jpeg|jpg|png|gif|webp|svg/;
268
+ const extname = allowedTypes.test(path_1.default.extname(file.originalname).toLowerCase());
269
+ const mimetype = allowedTypes.test(file.mimetype);
270
+ if (extname && mimetype) {
271
+ return cb(null, true);
272
+ }
273
+ cb(new Error('Only image files (JPG, PNG, GIF, WEBP, SVG) are allowed!'));
274
+ }
275
+ });
276
+ function getMediaFiles() {
277
+ if (!fs_1.default.existsSync(UPLOAD_DIR))
278
+ return [];
279
+ const files = fs_1.default.readdirSync(UPLOAD_DIR);
280
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
281
+ return files
282
+ .filter(file => imageExtensions.includes(path_1.default.extname(file).toLowerCase()))
283
+ .map(file => {
284
+ const stats = fs_1.default.statSync(path_1.default.join(UPLOAD_DIR, file));
285
+ return {
286
+ name: file,
287
+ url: `/uploads/${file}`,
288
+ size: (stats.size / 1024).toFixed(1) + ' KB',
289
+ mtime: stats.mtime
290
+ };
291
+ })
292
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
293
+ }
294
+ // ==========================================
295
+ // 2. DASHBOARD & PAGE MANAGEMENT ROUTES
296
+ // ==========================================
297
+ // GET /admin - Dashboard Overview
298
+ router.get('/', requireRole('Editor'), async (_req, res) => {
299
+ try {
300
+ const [totalRows] = await db_1.default.execute('SELECT COUNT(*) AS totalPages FROM pages WHERE status != "Deleted"');
301
+ const [pubRows] = await db_1.default.execute('SELECT COUNT(*) AS publishedPages FROM pages WHERE status = "Published"');
302
+ const [draftRows] = await db_1.default.execute('SELECT COUNT(*) AS draftPages FROM pages WHERE status = "Draft"');
303
+ const [trashRows] = await db_1.default.execute('SELECT COUNT(*) AS trashPages FROM pages WHERE status = "Deleted"');
304
+ const [userRows] = await db_1.default.execute('SELECT COUNT(*) AS totalUsers FROM users');
305
+ const pagesDir = path_1.default.join(__dirname, '../../views/pages');
306
+ let staticFiles = [];
307
+ if (fs_1.default.existsSync(pagesDir)) {
308
+ staticFiles = fs_1.default.readdirSync(pagesDir).filter(file => file.endsWith('.ejs'));
309
+ }
310
+ res.render('admin/dashboard', {
311
+ stats: {
312
+ totalPages: totalRows[0]?.totalPages || 0,
313
+ publishedPages: pubRows[0]?.publishedPages || 0,
314
+ draftPages: draftRows[0]?.draftPages || 0,
315
+ trashPages: trashRows[0]?.trashPages || 0,
316
+ staticPagesCount: staticFiles.length,
317
+ totalUsers: userRows[0]?.totalUsers || 0,
318
+ mediaCount: getMediaFiles().length
319
+ },
320
+ user: _req.session?.user
321
+ });
322
+ }
323
+ catch (err) {
324
+ console.error('[Radix Dashboard Stats Error]:', err);
325
+ res.status(500).render('defaults/500');
326
+ }
327
+ });
328
+ // GET /admin/pages - Pages Management List
329
+ router.get('/pages', requireRole('Editor'), async (req, res) => {
330
+ try {
331
+ const showTrash = req.query.view === 'trash';
332
+ const statusFilter = showTrash ? 'Deleted' : req.query.status || null;
333
+ let sql = 'SELECT * FROM pages ';
334
+ const params = [];
335
+ if (showTrash) {
336
+ sql += 'WHERE status = "Deleted" ';
337
+ }
338
+ else if (statusFilter) {
339
+ sql += 'WHERE status = ? ';
340
+ params.push(statusFilter);
341
+ }
342
+ else {
343
+ sql += 'WHERE status != "Deleted" ';
344
+ }
345
+ sql += 'ORDER BY created_at DESC';
346
+ const [pages] = await db_1.default.execute(sql, params);
347
+ const pagesDir = path_1.default.join(__dirname, '../../views/pages');
348
+ let staticSlugs = [];
349
+ if (fs_1.default.existsSync(pagesDir)) {
350
+ staticSlugs = fs_1.default.readdirSync(pagesDir)
351
+ .filter(file => file.endsWith('.ejs'))
352
+ .map(file => path_1.default.basename(file, '.ejs').toLowerCase());
353
+ }
354
+ const pagesWithFlags = pages.map(page => ({
355
+ ...page,
356
+ isStaticOverride: staticSlugs.includes(String(page.slug).toLowerCase())
357
+ }));
358
+ res.render('admin/pages', {
359
+ pages: pagesWithFlags,
360
+ showTrash,
361
+ currentStatusFilter: statusFilter || 'All',
362
+ user: req.session?.user
363
+ });
364
+ }
365
+ catch (err) {
366
+ console.error('[Radix Pages Route Error]:', err);
367
+ res.status(500).render('defaults/500');
368
+ }
369
+ });
370
+ // GET /admin/create - Render Create Form
371
+ router.get('/create', requireRole('Editor'), async (req, res) => {
372
+ try {
373
+ const templates = (0, themeScanner_1.getAvailableTemplates)();
374
+ res.render('admin/create-page', {
375
+ templates,
376
+ user: req.session?.user
377
+ });
378
+ }
379
+ catch (err) {
380
+ console.error('[Radix Create Page Route Error]:', err);
381
+ res.status(500).render('defaults/500');
382
+ }
383
+ });
384
+ // POST /admin/create - Process Page Creation
385
+ router.post('/create', requireRole('Editor'), async (req, res) => {
386
+ const { title, slug, content, template_name, status, min_role, publish_at } = req.body;
387
+ try {
388
+ const publishDate = publish_at ? new Date(publish_at) : new Date();
389
+ const selectedTemplate = template_name && template_name.trim() !== '' ? template_name : 'default';
390
+ await db_1.default.execute(`INSERT INTO pages (title, slug, content, template_name, status, min_role, publish_at, created_at)
391
+ VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`, [
392
+ title,
393
+ slug,
394
+ content || '',
395
+ selectedTemplate,
396
+ status || 'Draft',
397
+ min_role || 'Unregistered',
398
+ publishDate
399
+ ]);
400
+ res.redirect('/admin/pages');
401
+ }
402
+ catch (err) {
403
+ console.error('[Radix Page Creation Error]:', err);
404
+ res.status(500).render('defaults/500');
405
+ }
406
+ });
407
+ // GET /admin/edit/:id - Render Edit Page Form
408
+ router.get('/edit/:id', requireRole('Editor'), async (req, res) => {
409
+ try {
410
+ const [pages] = await db_1.default.execute('SELECT * FROM pages WHERE id = ?', [req.params.id]);
411
+ if (pages.length === 0) {
412
+ res.status(404).render('defaults/404');
413
+ return;
414
+ }
415
+ const templates = (0, themeScanner_1.getAvailableTemplates)();
416
+ res.render('admin/edit-page', {
417
+ page: pages[0],
418
+ templates,
419
+ user: req.session?.user
420
+ });
421
+ }
422
+ catch (err) {
423
+ console.error('[Radix Edit Fetch Error]:', err);
424
+ res.status(500).render('defaults/500');
425
+ }
426
+ });
427
+ // POST /admin/edit/:id - Process Edit Page Update
428
+ router.post('/edit/:id', requireRole('Editor'), async (req, res) => {
429
+ const pageId = req.params.id;
430
+ const { title, slug, content, template_name, status, min_role, publish_at } = req.body;
431
+ try {
432
+ const publishDate = publish_at ? new Date(publish_at) : new Date();
433
+ await db_1.default.execute(`UPDATE pages
434
+ SET title = ?, slug = ?, content = ?, template_name = ?, status = ?, min_role = ?, publish_at = ?
435
+ WHERE id = ?`, [
436
+ title,
437
+ slug,
438
+ content || '',
439
+ template_name || 'default',
440
+ status || 'Draft',
441
+ min_role || 'Unregistered',
442
+ publishDate,
443
+ pageId
444
+ ]);
445
+ res.redirect('/admin/pages');
446
+ }
447
+ catch (err) {
448
+ console.error('[Radix Page Update Error]:', err);
449
+ res.status(500).render('defaults/500');
450
+ }
451
+ });
452
+ // POST /admin/delete/:id - Soft Delete Page
453
+ router.post('/delete/:id', requireRole('Editor'), async (req, res) => {
454
+ try {
455
+ await db_1.default.execute('UPDATE pages SET status = "Deleted" WHERE id = ?', [req.params.id]);
456
+ res.redirect('/admin/pages');
457
+ }
458
+ catch (err) {
459
+ console.error('[Radix Soft Delete Error]:', err);
460
+ res.status(500).render('defaults/500');
461
+ }
462
+ });
463
+ // POST /admin/restore/:id - Restore Page from Trash
464
+ router.post('/restore/:id', requireRole('Editor'), async (req, res) => {
465
+ try {
466
+ await db_1.default.execute('UPDATE pages SET status = "Draft" WHERE id = ?', [req.params.id]);
467
+ res.redirect('/admin/pages?view=trash');
468
+ }
469
+ catch (err) {
470
+ console.error('[Radix Restore Error]:', err);
471
+ res.status(500).render('defaults/500');
472
+ }
473
+ });
474
+ // POST /admin/permanent-delete/:id - Hard Delete Page
475
+ router.post('/permanent-delete/:id', requireRole('Administrator'), async (req, res) => {
476
+ try {
477
+ await db_1.default.execute('DELETE FROM pages WHERE id = ? AND status = "Deleted"', [req.params.id]);
478
+ res.redirect('/admin/pages?view=trash');
479
+ }
480
+ catch (err) {
481
+ console.error('[Radix Permanent Delete Error]:', err);
482
+ res.status(500).render('defaults/500');
483
+ }
484
+ });
485
+ // ==========================================
486
+ // 3. USER MANAGEMENT & RBAC ROUTES
487
+ // ==========================================
488
+ router.get('/users', requireRole('Administrator'), async (req, res) => {
489
+ try {
490
+ const [users] = await db_1.default.execute('SELECT id, username, email, full_name, role, created_at FROM users ORDER BY created_at DESC');
491
+ res.render('admin/users', {
492
+ users,
493
+ user: req.session?.user
494
+ });
495
+ }
496
+ catch (err) {
497
+ console.error('[Radix User Fetch Error]:', err);
498
+ res.status(500).render('defaults/500');
499
+ }
500
+ });
501
+ router.post('/users/create', requireRole('Administrator'), async (req, res) => {
502
+ const { username, email, full_name, password, role } = req.body;
503
+ if (!username || !password || !role) {
504
+ res.status(400).send('Username, password, and role are required.');
505
+ return;
506
+ }
507
+ const validRoles = ['Administrator', 'Manager', 'Editor', 'Registered', 'Unregistered'];
508
+ if (!validRoles.includes(role)) {
509
+ res.status(400).send('Invalid role specified.');
510
+ return;
511
+ }
512
+ try {
513
+ const [existing] = await db_1.default.execute('SELECT id FROM users WHERE username = ? OR (email IS NOT NULL AND email = ? AND email != "")', [username, email || '']);
514
+ if (existing.length > 0) {
515
+ res.status(400).send('User with that username or email already exists.');
516
+ return;
517
+ }
518
+ const hashedPassword = await bcrypt_1.default.hash(password, 10);
519
+ await db_1.default.execute('INSERT INTO users (username, email, full_name, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, NOW())', [username, email || null, full_name || null, hashedPassword, role]);
520
+ res.redirect('/admin/users');
521
+ }
522
+ catch (err) {
523
+ console.error('[Radix User Creation Error]:', err);
524
+ res.status(500).render('defaults/500');
525
+ }
526
+ });
527
+ router.get('/users/edit/:id', requireRole('Administrator'), async (req, res) => {
528
+ try {
529
+ const [users] = await db_1.default.execute('SELECT id, username, email, full_name, role FROM users WHERE id = ?', [req.params.id]);
530
+ if (users.length === 0) {
531
+ res.status(404).render('defaults/404');
532
+ return;
533
+ }
534
+ res.render('admin/edit-user', {
535
+ editUser: users[0],
536
+ user: req.session?.user
537
+ });
538
+ }
539
+ catch (err) {
540
+ console.error('[Radix User Edit Fetch Error]:', err);
541
+ res.status(500).render('defaults/500');
542
+ }
543
+ });
544
+ router.post('/users/edit/:id', requireRole('Administrator'), async (req, res) => {
545
+ const userId = req.params.id;
546
+ const { username, email, full_name, password, role } = req.body;
547
+ if (!username || !role) {
548
+ res.status(400).send('Username and role are required.');
549
+ return;
550
+ }
551
+ const validRoles = ['Administrator', 'Manager', 'Editor', 'Registered', 'Unregistered'];
552
+ if (!validRoles.includes(role)) {
553
+ res.status(400).send('Invalid role specified.');
554
+ return;
555
+ }
556
+ try {
557
+ const [existing] = await db_1.default.execute('SELECT id FROM users WHERE (username = ? OR (email IS NOT NULL AND email = ? AND email != "")) AND id != ?', [username, email || '', userId]);
558
+ if (existing.length > 0) {
559
+ res.status(400).send('Another user with that username or email already exists.');
560
+ return;
561
+ }
562
+ if (password && password.trim()) {
563
+ const hashedPassword = await bcrypt_1.default.hash(password, 10);
564
+ await db_1.default.execute('UPDATE users SET username = ?, email = ?, full_name = ?, role = ?, password_hash = ? WHERE id = ?', [username, email || null, full_name || null, role, hashedPassword, userId]);
565
+ }
566
+ else {
567
+ await db_1.default.execute('UPDATE users SET username = ?, email = ?, full_name = ?, role = ? WHERE id = ?', [username, email || null, full_name || null, role, userId]);
568
+ }
569
+ const sessionUser = req.session?.user;
570
+ if (sessionUser && String(sessionUser.id) === String(userId)) {
571
+ sessionUser.username = username;
572
+ sessionUser.role = role;
573
+ }
574
+ res.redirect('/admin/users');
575
+ }
576
+ catch (err) {
577
+ console.error('[Radix User Update Error]:', err);
578
+ res.status(500).render('defaults/500');
579
+ }
580
+ });
581
+ router.post('/users/update-role', requireRole('Administrator'), async (req, res) => {
582
+ const { userId, newRole } = req.body;
583
+ const validRoles = ['Administrator', 'Manager', 'Editor', 'Registered', 'Unregistered'];
584
+ if (!validRoles.includes(newRole)) {
585
+ res.status(400).send('Invalid role specified.');
586
+ return;
587
+ }
588
+ try {
589
+ await db_1.default.execute('UPDATE users SET role = ? WHERE id = ?', [newRole, userId]);
590
+ res.redirect('/admin/users');
591
+ }
592
+ catch (err) {
593
+ console.error('[Radix Role Update Error]:', err);
594
+ res.status(500).render('defaults/500');
595
+ }
596
+ });
597
+ router.post('/users/delete/:id', requireRole('Administrator'), async (req, res) => {
598
+ const targetUserId = req.params.id;
599
+ const currentUserId = req.session?.user?.id;
600
+ if (String(targetUserId) === String(currentUserId)) {
601
+ res.status(400).send('Security error: You cannot delete your own active administrator account.');
602
+ return;
603
+ }
604
+ try {
605
+ await db_1.default.execute('DELETE FROM users WHERE id = ?', [targetUserId]);
606
+ res.redirect('/admin/users');
607
+ }
608
+ catch (err) {
609
+ console.error('[Radix User Delete Error]:', err);
610
+ res.status(500).render('defaults/500');
611
+ }
612
+ });
613
+ // ==========================================
614
+ // 4. MEDIA LIBRARY ROUTES
615
+ // ==========================================
616
+ router.get('/media', requireRole('Editor'), (req, res) => {
617
+ const media = getMediaFiles();
618
+ res.render('admin/media', { media, user: req.session?.user });
619
+ });
620
+ router.post('/media/upload', requireRole('Editor'), upload.single('file'), (req, res) => {
621
+ if (!req.file) {
622
+ res.status(400).json({ error: 'No file uploaded.' });
623
+ return;
624
+ }
625
+ res.json({ location: `/uploads/${req.file.filename}` });
626
+ });
627
+ router.post('/media/delete', requireRole('Administrator'), (req, res) => {
628
+ const { filename } = req.body;
629
+ if (!filename) {
630
+ res.status(400).send('Filename required.');
631
+ return;
632
+ }
633
+ const filePath = path_1.default.join(UPLOAD_DIR, path_1.default.basename(filename));
634
+ if (fs_1.default.existsSync(filePath)) {
635
+ fs_1.default.unlinkSync(filePath);
636
+ }
637
+ res.redirect('/admin/media');
638
+ });
639
+ // ==========================================
640
+ // 5. THEME MANAGEMENT
641
+ // ==========================================
642
+ router.get('/themes', requireRole('Editor'), async (req, res) => {
643
+ try {
644
+ const templates = (0, themeScanner_1.getAvailableTemplates)();
645
+ res.render('admin/themes', {
646
+ templates,
647
+ user: req.session?.user
648
+ });
649
+ }
650
+ catch (err) {
651
+ console.error('[Radix Themes Route Error]:', err);
652
+ res.status(500).render('defaults/500');
653
+ }
654
+ });
655
+ // ==========================================
656
+ // 6. SETTINGS
657
+ // ==========================================
658
+ // GET /admin/settings - Render Settings Page
659
+ router.get('/settings', requireRole('Administrator'), async (req, res) => {
660
+ try {
661
+ const [rows] = await db_1.default.execute('SELECT * FROM settings');
662
+ const settings = {};
663
+ rows.forEach(row => {
664
+ settings[row.setting_key] = row.setting_value || '';
665
+ });
666
+ res.render('admin/settings', {
667
+ settings,
668
+ message: req.query.msg ? String(req.query.msg) : null,
669
+ error: req.query.err ? String(req.query.err) : null,
670
+ user: req.session?.user
671
+ });
672
+ }
673
+ catch (err) {
674
+ console.error('[Radix Settings GET Error]:', err);
675
+ res.status(500).render('defaults/500');
676
+ }
677
+ });
678
+ // POST /admin/settings - Save Settings
679
+ router.post('/settings', requireRole('Administrator'), async (req, res) => {
680
+ const { site_logo, mail_host, mail_port, mail_secure, mail_user, mail_pass, mail_from } = req.body;
681
+ const updates = [
682
+ ['site_logo', site_logo || ''],
683
+ ['mail_host', mail_host || ''],
684
+ ['mail_port', mail_port || '25'],
685
+ ['mail_secure', mail_secure === '1' ? '1' : '0'],
686
+ ['mail_user', mail_user || ''],
687
+ ['mail_pass', mail_pass || ''],
688
+ ['mail_from', mail_from || '']
689
+ ];
690
+ try {
691
+ for (const [key, value] of updates) {
692
+ await db_1.default.execute('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = ?', [key, value, value]);
693
+ }
694
+ res.redirect('/admin/settings?msg=Settings+saved+successfully.');
695
+ }
696
+ catch (err) {
697
+ console.error('[Radix Settings Save Error]:', err);
698
+ res.redirect('/admin/settings?err=Failed+to+save+settings.');
699
+ }
700
+ });
701
+ // POST /admin/settings/test-email - Dispatch SMTP Test Email
702
+ router.post('/settings/test-email', requireRole('Administrator'), async (req, res) => {
703
+ const { recipient_email } = req.body;
704
+ if (!recipient_email) {
705
+ res.redirect('/admin/settings?err=Recipient+email+address+is+required.');
706
+ return;
707
+ }
708
+ try {
709
+ const [rows] = await db_1.default.execute('SELECT * FROM settings');
710
+ const cfg = {};
711
+ rows.forEach(r => { cfg[r.setting_key] = r.setting_value || ''; });
712
+ const transporter = nodemailer_1.default.createTransport({
713
+ host: cfg.mail_host || process.env.MAIL_HOST || 'localhost',
714
+ port: Number(cfg.mail_port || process.env.MAIL_PORT || 25),
715
+ secure: cfg.mail_secure === '1',
716
+ auth: cfg.mail_user ? {
717
+ user: cfg.mail_user,
718
+ pass: cfg.mail_pass
719
+ } : undefined,
720
+ tls: { rejectUnauthorized: false }
721
+ });
722
+ await transporter.sendMail({
723
+ from: cfg.mail_from || `Radix CMS <noreply@${process.env.SITE_URL || 'localhost'}>`,
724
+ to: recipient_email,
725
+ subject: 'Radix CMS - SMTP Test Connection',
726
+ text: 'Congratulations! Your Radix CMS mail server configuration is working correctly.',
727
+ html: '<h3>Radix CMS Mail Test</h3><p>Your mail server settings are configured correctly.</p>'
728
+ });
729
+ res.redirect('/admin/settings?msg=Test+email+sent+successfully+to+' + encodeURIComponent(recipient_email));
730
+ }
731
+ catch (err) {
732
+ console.error('[SMTP Test Error]:', err);
733
+ res.redirect('/admin/settings?err=' + encodeURIComponent(`Email dispatch failed: ${err.message}`));
734
+ }
735
+ });
736
+ // ==========================================
737
+ // 7. UPDATES
738
+ // ==========================================
739
+ // GET /admin/updates - Render System Updates Dashboard
740
+ router.get('/updates', requireRole('Editor'), async (req, res) => {
741
+ try {
742
+ const updateInfo = await (0, versionCheck_1.checkForUpdates)();
743
+ res.render('admin/updates', {
744
+ updateInfo,
745
+ message: req.query.msg ? String(req.query.msg) : null,
746
+ error: req.query.err ? String(req.query.err) : null,
747
+ user: req.session?.user
748
+ });
749
+ }
750
+ catch (err) {
751
+ console.error('[Radix Updates Route Error]:', err);
752
+ res.status(500).render('defaults/500');
753
+ }
754
+ });
755
+ // POST /admin/updates/apply - Download & Overwrite Core Codebase
756
+ router.post('/updates/apply', requireRole('Administrator'), async (req, res) => {
757
+ const updateInfo = await (0, versionCheck_1.checkForUpdates)();
758
+ if (!updateInfo.hasUpdate || !updateInfo.details) {
759
+ res.redirect('/admin/updates?err=' + encodeURIComponent('No update available or release package details missing.'));
760
+ return;
761
+ }
762
+ const fileUrl = `https://vault.ultraspark.net/radix/${updateInfo.details.InstallFile}`;
763
+ const zipPath = path_1.default.join(process.cwd(), 'temp_update.zip');
764
+ const extractDir = path_1.default.join(process.cwd(), 'temp_update_extract');
765
+ try {
766
+ // 1. Download zip archive
767
+ const response = await fetch(fileUrl);
768
+ if (!response.ok)
769
+ throw new Error(`Download failed with status ${response.status}`);
770
+ const arrayBuffer = await response.arrayBuffer();
771
+ await fs_1.default.promises.writeFile(zipPath, Buffer.from(arrayBuffer));
772
+ // 2. Extract contents
773
+ const zip = new adm_zip_1.default(zipPath);
774
+ zip.extractAllTo(extractDir, true);
775
+ // 3. Overwrite codebase excluding environment and runtime assets
776
+ const protectedPaths = ['.env', 'node_modules', 'uploads', 'temp_update.zip', 'temp_update_extract'];
777
+ const copyFolderRecursive = async (src, dest) => {
778
+ const entries = await fs_1.default.promises.readdir(src, { withFileTypes: true });
779
+ for (const entry of entries) {
780
+ if (protectedPaths.includes(entry.name))
781
+ continue;
782
+ const srcPath = path_1.default.join(src, entry.name);
783
+ const destPath = path_1.default.join(dest, entry.name);
784
+ if (entry.isDirectory()) {
785
+ await fs_1.default.promises.mkdir(destPath, { recursive: true });
786
+ await copyFolderRecursive(srcPath, destPath);
787
+ }
788
+ else {
789
+ await fs_1.default.promises.copyFile(srcPath, destPath);
790
+ }
791
+ }
792
+ };
793
+ await copyFolderRecursive(extractDir, process.cwd());
794
+ updateEnvVersion(updateInfo.remoteVersion);
795
+ // 4. Clean up temporary files
796
+ await fs_1.default.promises.unlink(zipPath).catch(() => { });
797
+ await fs_1.default.promises.rm(extractDir, { recursive: true, force: true }).catch(() => { });
798
+ res.redirect('/admin/updates?msg=' + encodeURIComponent('System updated successfully! Restart application process to reflect server changes.'));
799
+ }
800
+ catch (err) {
801
+ console.error('[Update Apply Error]:', err);
802
+ // Cleanup on failure
803
+ await fs_1.default.promises.unlink(zipPath).catch(() => { });
804
+ await fs_1.default.promises.rm(extractDir, { recursive: true, force: true }).catch(() => { });
805
+ res.redirect('/admin/updates?err=' + encodeURIComponent(`Update execution failed: ${err.message}`));
806
+ }
807
+ });
808
+ exports.default = router;