domma-cms 0.9.0 → 0.9.5

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 (44) hide show
  1. package/admin/js/templates/block-editor.html +163 -163
  2. package/admin/js/templates/form-editor.html +245 -245
  3. package/admin/js/views/action-editor.js +1 -1
  4. package/admin/js/views/block-editor.js +8 -8
  5. package/admin/js/views/collection-editor.js +4 -4
  6. package/admin/js/views/collections.js +1 -1
  7. package/admin/js/views/form-editor.js +7 -7
  8. package/admin/js/views/forms.js +1 -1
  9. package/admin/js/views/navigation.js +14 -14
  10. package/admin/js/views/page-editor.js +35 -35
  11. package/admin/js/views/pages.js +5 -5
  12. package/admin/js/views/plugins.js +19 -10
  13. package/admin/js/views/view-editor.js +1 -1
  14. package/config/plugins.json +35 -0
  15. package/package.json +1 -1
  16. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +4 -4
  17. package/plugins/docs/data/folders.json +3 -3
  18. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +5 -0
  19. package/plugins/garage/admin/templates/garage.html +30 -0
  20. package/plugins/garage/admin/views/garage.js +62 -1
  21. package/plugins/garage/plugin.json +1 -1
  22. package/plugins/notes/admin/templates/notes.html +2 -11
  23. package/plugins/notes/admin/views/notes.js +107 -129
  24. package/plugins/notes/collections/user-notes/schema.json +2 -1
  25. package/plugins/notes/plugin.json +1 -1
  26. package/plugins/site-search/admin/templates/site-search.html +174 -46
  27. package/plugins/site-search/admin/views/site-search.js +72 -1
  28. package/plugins/site-search/config.js +6 -1
  29. package/plugins/site-search/plugin.json +1 -1
  30. package/plugins/site-search/public/inject-head.html +1 -1
  31. package/plugins/site-search/public/search.css +1 -1
  32. package/plugins/site-search/public/search.js +1 -1
  33. package/plugins/todo/admin/templates/todo.html +2 -8
  34. package/plugins/todo/admin/views/todo.js +122 -106
  35. package/plugins/todo/collections/todos/schema.json +2 -1
  36. package/plugins/todo/plugin.json +1 -1
  37. package/server/routes/api/media.js +127 -118
  38. package/server/routes/api/plugins.js +15 -4
  39. package/server/server.js +288 -285
  40. package/server/services/blocks.js +6 -3
  41. package/server/services/collections.js +17 -10
  42. package/server/services/plugins.js +77 -67
  43. package/server/services/renderer.js +3 -3
  44. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +0 -11
package/server/server.js CHANGED
@@ -1,285 +1,288 @@
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
- * Updated: blobs + z-index fixes.
6
- */
7
- import 'dotenv/config';
8
- import Fastify from 'fastify';
9
- import jwt from '@fastify/jwt';
10
- import staticPlugin from '@fastify/static';
11
- import cors from '@fastify/cors';
12
- import multipart from '@fastify/multipart';
13
- import rateLimit from '@fastify/rate-limit';
14
- import helmet from '@fastify/helmet';
15
- import path from 'path';
16
- import fs from 'fs/promises';
17
- import {fileURLToPath} from 'url';
18
- import {createRequire} from 'module';
19
- import {config, getConfig} from './config.js';
20
- import {registerPlugins} from './services/plugins.js';
21
- import {load as loadRoles, seed as seedRoles} from './services/roles.js';
22
- import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
23
- import {seedAll as seedPresetCollections} from './services/presetCollections.js';
24
- import {seedDefaultBlocks} from './services/blocks.js';
25
-
26
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
- const ROOT = path.resolve(__dirname, '..');
28
-
29
- // Resolve domma-js package location via require resolution
30
- const require = createRequire(import.meta.url);
31
- const dommaPackageDir = path.dirname(require.resolve('domma-js/package.json'));
32
- const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
33
-
34
- const { server: serverConfig, auth: authConfig } = config;
35
-
36
- // Validate JWT_SECRET before starting — prevents silent auth failures
37
- const JWT_SECRET = process.env.JWT_SECRET;
38
- if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
39
- console.error('');
40
- console.error(' ERROR: JWT_SECRET is not set or is insecure.');
41
- console.error(' Run `npm run setup` or set a secure JWT_SECRET in .env');
42
- console.error(' (minimum 32 characters, not "CHANGE_ME")');
43
- console.error('');
44
- process.exit(1);
45
- }
46
-
47
- const app = Fastify({
48
- logger: {level: process.env.NODE_ENV === 'development' ? 'info' : 'warn'},
49
- // When running behind a reverse proxy (e.g. domma-cms-manager), trust the
50
- // X-Forwarded-For header so @fastify/rate-limit keys on the real client IP
51
- // rather than the proxy's loopback address.
52
- trustProxy: !!process.env.TRUST_PROXY,
53
- });
54
-
55
- // Register process error handlers immediately so startup errors are captured
56
- process.on('uncaughtException', (err) => {
57
- app.log.error({ err }, 'Uncaught exception');
58
- process.exit(1);
59
- });
60
-
61
- process.on('unhandledRejection', (reason) => {
62
- app.log.error({ reason }, 'Unhandled rejection');
63
- process.exit(1);
64
- });
65
-
66
- // ---------------------------------------------------------------------------
67
- // Core Plugins
68
- // ---------------------------------------------------------------------------
69
-
70
- await app.register(helmet, {
71
- contentSecurityPolicy: {
72
- directives: {
73
- defaultSrc: ["'self'"],
74
- scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
75
- styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://fonts.googleapis.com'],
76
- imgSrc: ["'self'", 'data:', 'blob:'],
77
- fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
78
- connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
79
- upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
80
- }
81
- },
82
- crossOriginEmbedderPolicy: false, // allow embedding images/resources
83
- hsts: false, // disable HSTS — server runs HTTP only; HSTS would force browser to https
84
- });
85
-
86
- await app.register(jwt, { secret: process.env.JWT_SECRET });
87
- await app.register(cors, serverConfig.cors);
88
- await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
89
- await app.register(rateLimit, {
90
- global: true, // apply default limit to all routes; stricter per-route limits override this
91
- max: 500,
92
- timeWindow: '1 minute',
93
- // Loopback is always exempt — local admin use should never be rate-limited.
94
- // When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
95
- allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
96
- });
97
-
98
- // ---------------------------------------------------------------------------
99
- // Static Assets
100
- // ---------------------------------------------------------------------------
101
-
102
- // Serve Domma dist files from npm package (shared by admin + public)
103
- await app.register(staticPlugin, {
104
- root: DOMMA_DIST,
105
- prefix: '/dist/domma/',
106
- decorateReply: false
107
- });
108
-
109
- // Serve public site assets (CSS, JS, images, etc.)
110
- await app.register(staticPlugin, {
111
- root: path.join(ROOT, 'public'),
112
- prefix: '/public/',
113
- decorateReply: false
114
- });
115
-
116
- // Serve admin panel assets — no-cache so JS/CSS changes are always picked up
117
- await app.register(staticPlugin, {
118
- root: path.join(ROOT, 'admin'),
119
- prefix: '/admin/',
120
- decorateReply: false,
121
- setHeaders: (res) => {
122
- res.setHeader('Cache-Control', 'no-cache');
123
- }
124
- });
125
-
126
- // Ensure required directories exist
127
- const mediaDir = path.join(ROOT, config.content.mediaDir);
128
- const usersDir = path.join(ROOT, config.content.usersDir);
129
- const collectionsDir = path.join(ROOT, config.content.collectionsDir);
130
- const pluginsDir = path.join(ROOT, 'plugins');
131
- const blocksDir = path.join(ROOT, 'content', 'blocks');
132
- await fs.mkdir(mediaDir, { recursive: true });
133
- await fs.mkdir(usersDir, { recursive: true });
134
- await fs.mkdir(collectionsDir, { recursive: true });
135
- await fs.mkdir(pluginsDir, { recursive: true });
136
- await fs.mkdir(blocksDir, {recursive: true});
137
-
138
- // ---------------------------------------------------------------------------
139
- // Pro feature — optional MongoDB connections
140
- // ---------------------------------------------------------------------------
141
-
142
- try {
143
- const connections = getConfig('connections');
144
- if (connections && Object.keys(connections).length > 0) {
145
- const { initialise, shutdown } = await import('./services/connectionManager.js');
146
- await initialise(connections);
147
- app.addHook('onClose', shutdown);
148
- }
149
- } catch {
150
- // No connections.json or empty — pure file-based mode (free version).
151
- }
152
-
153
- // ---------------------------------------------------------------------------
154
- // Roles — seed preset collection + load into cache
155
- // ---------------------------------------------------------------------------
156
-
157
- await seedRoles();
158
- await loadRoles();
159
- await seedUserProfiles();
160
- await ensureAllProfiles();
161
- await seedPresetCollections();
162
- await seedDefaultBlocks();
163
-
164
- // Serve uploaded media files
165
- await app.register(staticPlugin, {
166
- root: mediaDir,
167
- prefix: '/media/',
168
- decorateReply: false
169
- });
170
-
171
- // Serve plugin admin/ and public/ subdirs only — block plugin.js, config.js, data/, etc.
172
- await app.register(async function pluginStaticScope(instance) {
173
- instance.addHook('onRequest', (request, reply, done) => {
174
- const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
175
- const segments = relative.split('/');
176
-
177
- if (relative.includes('..')) {
178
- reply.code(403).send({error: 'Forbidden'});
179
- return;
180
- }
181
- // Must be: {pluginName}/{admin|public}/{file...}
182
- if (segments.length < 3 || !['admin', 'public'].includes(segments[1])) {
183
- reply.code(404).send({error: 'Not found'});
184
- return;
185
- }
186
- done();
187
- });
188
-
189
- await instance.register(staticPlugin, {
190
- root: pluginsDir,
191
- prefix: '/plugins/',
192
- decorateReply: false
193
- });
194
- });
195
-
196
- // ---------------------------------------------------------------------------
197
- // Global error handler — prevents stack trace leaks in production
198
- // ---------------------------------------------------------------------------
199
-
200
- app.setErrorHandler((error, request, reply) => {
201
- app.log.error({ err: error, url: request.url }, 'Request error');
202
- const statusCode = error.statusCode || 500;
203
- // Don't leak stack traces in production
204
- const message = process.env.NODE_ENV === 'production' && statusCode === 500
205
- ? 'Internal server error'
206
- : error.message;
207
- return reply.code(statusCode).send({ error: message });
208
- });
209
-
210
- // ---------------------------------------------------------------------------
211
- // Auth API Routes (no authentication required on these endpoints themselves)
212
- // ---------------------------------------------------------------------------
213
-
214
- const { authRoutes } = await import('./routes/api/auth.js');
215
- await app.register(authRoutes, { prefix: '/api' });
216
-
217
- // ---------------------------------------------------------------------------
218
- // Protected API Routes
219
- // ---------------------------------------------------------------------------
220
-
221
- const { pagesRoutes } = await import('./routes/api/pages.js');
222
- const { settingsRoutes } = await import('./routes/api/settings.js');
223
- const { layoutsRoutes } = await import('./routes/api/layouts.js');
224
- const { navigationRoutes } = await import('./routes/api/navigation.js');
225
- const { mediaRoutes } = await import('./routes/api/media.js');
226
- const { usersRoutes } = await import('./routes/api/users.js');
227
- const { pluginsRoutes } = await import('./routes/api/plugins.js');
228
- const { collectionsRoutes } = await import('./routes/api/collections.js');
229
- const { formsRoutes } = await import('./routes/api/forms.js');
230
- const { viewsRoutes } = await import('./routes/api/views.js');
231
- const { actionsRoutes } = await import('./routes/api/actions.js');
232
- const {blocksRoutes} = await import('./routes/api/blocks.js');
233
- const {versionsRoutes} = await import('./routes/api/versions.js');
234
- const {effectsRoutes} = await import('./routes/api/effects.js');
235
-
236
- await app.register(pagesRoutes, { prefix: '/api' });
237
- await app.register(settingsRoutes, { prefix: '/api' });
238
- await app.register(layoutsRoutes, { prefix: '/api' });
239
- await app.register(navigationRoutes, { prefix: '/api' });
240
- await app.register(mediaRoutes, { prefix: '/api' });
241
- await app.register(usersRoutes, { prefix: '/api' });
242
- await app.register(pluginsRoutes, { prefix: '/api' });
243
- await app.register(collectionsRoutes, { prefix: '/api' });
244
- await app.register(formsRoutes, { prefix: '/api' });
245
- await app.register(viewsRoutes, { prefix: '/api' });
246
- await app.register(actionsRoutes, { prefix: '/api' });
247
- await app.register(blocksRoutes, {prefix: '/api'});
248
- await app.register(versionsRoutes, {prefix: '/api'});
249
- await app.register(effectsRoutes, {prefix: '/api'});
250
-
251
- // ---------------------------------------------------------------------------
252
- // CMS Plugins (server-side Fastify plugins from plugins/ directory)
253
- // ---------------------------------------------------------------------------
254
-
255
- await registerPlugins(app);
256
-
257
- // ---------------------------------------------------------------------------
258
- // Public Site (catch-all — must be last)
259
- // ---------------------------------------------------------------------------
260
-
261
- const { publicRoutes } = await import('./routes/public.js');
262
- await app.register(publicRoutes);
263
-
264
- // ---------------------------------------------------------------------------
265
- // Graceful shutdown and unhandled error guards
266
- // ---------------------------------------------------------------------------
267
-
268
- const shutdown = async (signal) => {
269
- app.log.info(`Received ${signal}, shutting down gracefully...`);
270
- await app.close();
271
- process.exit(0);
272
- };
273
-
274
- process.on('SIGTERM', () => shutdown('SIGTERM'));
275
- process.on('SIGINT', () => shutdown('SIGINT'));
276
-
277
- try {
278
- await app.listen({ port: serverConfig.port, host: serverConfig.host });
279
- console.log(`Domma CMS running at http://localhost:${serverConfig.port}`);
280
- console.log(` Admin: http://localhost:${serverConfig.port}/admin/`);
281
- console.log(` Public: http://localhost:${serverConfig.port}/`);
282
- } catch (err) {
283
- app.log.error(err);
284
- process.exit(1);
285
- }
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
+ * Updated: blobs + z-index fixes.
6
+ */
7
+ import 'dotenv/config';
8
+ import Fastify from 'fastify';
9
+ import jwt from '@fastify/jwt';
10
+ import staticPlugin from '@fastify/static';
11
+ import cors from '@fastify/cors';
12
+ import multipart from '@fastify/multipart';
13
+ import rateLimit from '@fastify/rate-limit';
14
+ import helmet from '@fastify/helmet';
15
+ import path from 'path';
16
+ import fs from 'fs/promises';
17
+ import {fileURLToPath} from 'url';
18
+ import {createRequire} from 'module';
19
+ import {config, getConfig} from './config.js';
20
+ import {registerPlugins} from './services/plugins.js';
21
+ import {load as loadRoles, seed as seedRoles} from './services/roles.js';
22
+ import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
23
+ import {seedAll as seedPresetCollections} from './services/presetCollections.js';
24
+ import {seedDefaultBlocks} from './services/blocks.js';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ const ROOT = path.resolve(__dirname, '..');
28
+
29
+ // Resolve domma-js package location via require resolution
30
+ const require = createRequire(import.meta.url);
31
+ const dommaPackageDir = path.dirname(require.resolve('domma-js/package.json'));
32
+ const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
33
+
34
+ const { server: serverConfig, auth: authConfig } = config;
35
+
36
+ // Validate JWT_SECRET before starting — prevents silent auth failures
37
+ const JWT_SECRET = process.env.JWT_SECRET;
38
+ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
39
+ console.error('');
40
+ console.error(' ERROR: JWT_SECRET is not set or is insecure.');
41
+ console.error(' Run `npm run setup` or set a secure JWT_SECRET in .env');
42
+ console.error(' (minimum 32 characters, not "CHANGE_ME")');
43
+ console.error('');
44
+ process.exit(1);
45
+ }
46
+
47
+ const app = Fastify({
48
+ logger: {level: process.env.NODE_ENV === 'development' ? 'info' : 'warn'},
49
+ // When running behind a reverse proxy (e.g. domma-cms-manager), trust the
50
+ // X-Forwarded-For header so @fastify/rate-limit keys on the real client IP
51
+ // rather than the proxy's loopback address.
52
+ trustProxy: !!process.env.TRUST_PROXY,
53
+ });
54
+
55
+ // Register process error handlers immediately so startup errors are captured
56
+ process.on('uncaughtException', (err) => {
57
+ app.log.error({ err }, 'Uncaught exception');
58
+ process.exit(1);
59
+ });
60
+
61
+ process.on('unhandledRejection', (reason) => {
62
+ app.log.error({ reason }, 'Unhandled rejection');
63
+ process.exit(1);
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Core Plugins
68
+ // ---------------------------------------------------------------------------
69
+
70
+ await app.register(helmet, {
71
+ contentSecurityPolicy: {
72
+ directives: {
73
+ defaultSrc: ["'self'"],
74
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
75
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://fonts.googleapis.com'],
76
+ imgSrc: ["'self'", 'data:', 'blob:'],
77
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
78
+ connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
79
+ upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
80
+ }
81
+ },
82
+ crossOriginEmbedderPolicy: false, // allow embedding images/resources
83
+ hsts: false, // disable HSTS — server runs HTTP only; HSTS would force browser to https
84
+ });
85
+
86
+ await app.register(jwt, { secret: process.env.JWT_SECRET });
87
+ await app.register(cors, serverConfig.cors);
88
+ await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
89
+ await app.register(rateLimit, {
90
+ global: true, // apply default limit to all routes; stricter per-route limits override this
91
+ max: 500,
92
+ timeWindow: '1 minute',
93
+ // Loopback is always exempt — local admin use should never be rate-limited.
94
+ // When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
95
+ allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Static Assets
100
+ // ---------------------------------------------------------------------------
101
+
102
+ // Serve Domma dist files from npm package (shared by admin + public)
103
+ await app.register(staticPlugin, {
104
+ root: DOMMA_DIST,
105
+ prefix: '/dist/domma/',
106
+ decorateReply: false
107
+ });
108
+
109
+ // Serve public site assets (CSS, JS, images, etc.)
110
+ await app.register(staticPlugin, {
111
+ root: path.join(ROOT, 'public'),
112
+ prefix: '/public/',
113
+ decorateReply: false
114
+ });
115
+
116
+ // Serve admin panel assets — no-cache so JS/CSS changes are always picked up
117
+ await app.register(staticPlugin, {
118
+ root: path.join(ROOT, 'admin'),
119
+ prefix: '/admin/',
120
+ decorateReply: false,
121
+ setHeaders: (res) => {
122
+ res.setHeader('Cache-Control', 'no-cache');
123
+ }
124
+ });
125
+
126
+ // Ensure required directories exist
127
+ const mediaDir = path.join(ROOT, config.content.mediaDir);
128
+ const usersDir = path.join(ROOT, config.content.usersDir);
129
+ const collectionsDir = path.join(ROOT, config.content.collectionsDir);
130
+ const pluginsDir = path.join(ROOT, 'plugins');
131
+ const blocksDir = path.join(ROOT, 'content', 'blocks');
132
+ await fs.mkdir(mediaDir, { recursive: true });
133
+ await fs.mkdir(usersDir, { recursive: true });
134
+ await fs.mkdir(collectionsDir, { recursive: true });
135
+ await fs.mkdir(pluginsDir, { recursive: true });
136
+ await fs.mkdir(blocksDir, {recursive: true});
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Pro feature — optional MongoDB connections
140
+ // ---------------------------------------------------------------------------
141
+
142
+ try {
143
+ const connections = getConfig('connections');
144
+ if (connections && Object.keys(connections).length > 0) {
145
+ const { initialise, shutdown } = await import('./services/connectionManager.js');
146
+ await initialise(connections);
147
+ app.addHook('onClose', shutdown);
148
+ }
149
+ } catch {
150
+ // No connections.json or empty — pure file-based mode (free version).
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Roles — seed preset collection + load into cache
155
+ // ---------------------------------------------------------------------------
156
+
157
+ await seedRoles();
158
+ await loadRoles();
159
+ await seedUserProfiles();
160
+ await ensureAllProfiles();
161
+ await seedPresetCollections();
162
+ await seedDefaultBlocks();
163
+
164
+ // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
165
+ await app.register(staticPlugin, {
166
+ root: mediaDir,
167
+ prefix: '/media/',
168
+ decorateReply: false,
169
+ setHeaders: (res) => {
170
+ res.setHeader('X-Content-Type-Options', 'nosniff');
171
+ }
172
+ });
173
+
174
+ // Serve plugin admin/ and public/ subdirs only — block plugin.js, config.js, data/, etc.
175
+ await app.register(async function pluginStaticScope(instance) {
176
+ instance.addHook('onRequest', (request, reply, done) => {
177
+ const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
178
+ const segments = relative.split('/');
179
+
180
+ if (relative.includes('..')) {
181
+ reply.code(403).send({error: 'Forbidden'});
182
+ return;
183
+ }
184
+ // Must be: {pluginName}/{admin|public}/{file...}
185
+ if (segments.length < 3 || !['admin', 'public'].includes(segments[1])) {
186
+ reply.code(404).send({error: 'Not found'});
187
+ return;
188
+ }
189
+ done();
190
+ });
191
+
192
+ await instance.register(staticPlugin, {
193
+ root: pluginsDir,
194
+ prefix: '/plugins/',
195
+ decorateReply: false
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Global error handler prevents stack trace leaks in production
201
+ // ---------------------------------------------------------------------------
202
+
203
+ app.setErrorHandler((error, request, reply) => {
204
+ app.log.error({ err: error, url: request.url }, 'Request error');
205
+ const statusCode = error.statusCode || 500;
206
+ // Don't leak stack traces in production
207
+ const message = process.env.NODE_ENV === 'production' && statusCode === 500
208
+ ? 'Internal server error'
209
+ : error.message;
210
+ return reply.code(statusCode).send({ error: message });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Auth API Routes (no authentication required on these endpoints themselves)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ const { authRoutes } = await import('./routes/api/auth.js');
218
+ await app.register(authRoutes, { prefix: '/api' });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Protected API Routes
222
+ // ---------------------------------------------------------------------------
223
+
224
+ const { pagesRoutes } = await import('./routes/api/pages.js');
225
+ const { settingsRoutes } = await import('./routes/api/settings.js');
226
+ const { layoutsRoutes } = await import('./routes/api/layouts.js');
227
+ const { navigationRoutes } = await import('./routes/api/navigation.js');
228
+ const { mediaRoutes } = await import('./routes/api/media.js');
229
+ const { usersRoutes } = await import('./routes/api/users.js');
230
+ const { pluginsRoutes } = await import('./routes/api/plugins.js');
231
+ const { collectionsRoutes } = await import('./routes/api/collections.js');
232
+ const { formsRoutes } = await import('./routes/api/forms.js');
233
+ const { viewsRoutes } = await import('./routes/api/views.js');
234
+ const { actionsRoutes } = await import('./routes/api/actions.js');
235
+ const {blocksRoutes} = await import('./routes/api/blocks.js');
236
+ const {versionsRoutes} = await import('./routes/api/versions.js');
237
+ const {effectsRoutes} = await import('./routes/api/effects.js');
238
+
239
+ await app.register(pagesRoutes, { prefix: '/api' });
240
+ await app.register(settingsRoutes, { prefix: '/api' });
241
+ await app.register(layoutsRoutes, { prefix: '/api' });
242
+ await app.register(navigationRoutes, { prefix: '/api' });
243
+ await app.register(mediaRoutes, { prefix: '/api' });
244
+ await app.register(usersRoutes, { prefix: '/api' });
245
+ await app.register(pluginsRoutes, { prefix: '/api' });
246
+ await app.register(collectionsRoutes, { prefix: '/api' });
247
+ await app.register(formsRoutes, { prefix: '/api' });
248
+ await app.register(viewsRoutes, { prefix: '/api' });
249
+ await app.register(actionsRoutes, { prefix: '/api' });
250
+ await app.register(blocksRoutes, {prefix: '/api'});
251
+ await app.register(versionsRoutes, {prefix: '/api'});
252
+ await app.register(effectsRoutes, {prefix: '/api'});
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // CMS Plugins (server-side Fastify plugins from plugins/ directory)
256
+ // ---------------------------------------------------------------------------
257
+
258
+ await registerPlugins(app);
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Public Site (catch-all must be last)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ const { publicRoutes } = await import('./routes/public.js');
265
+ await app.register(publicRoutes);
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Graceful shutdown and unhandled error guards
269
+ // ---------------------------------------------------------------------------
270
+
271
+ const shutdown = async (signal) => {
272
+ app.log.info(`Received ${signal}, shutting down gracefully...`);
273
+ await app.close();
274
+ process.exit(0);
275
+ };
276
+
277
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
278
+ process.on('SIGINT', () => shutdown('SIGINT'));
279
+
280
+ try {
281
+ await app.listen({ port: serverConfig.port, host: serverConfig.host });
282
+ console.log(`Domma CMS running at http://localhost:${serverConfig.port}`);
283
+ console.log(` Admin: http://localhost:${serverConfig.port}/admin/`);
284
+ console.log(` Public: http://localhost:${serverConfig.port}/`);
285
+ } catch (err) {
286
+ app.log.error(err);
287
+ process.exit(1);
288
+ }
@@ -184,7 +184,8 @@ export async function listBlocks() {
184
184
  try {
185
185
  const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
186
186
  bundled = !!meta.bundled;
187
- } catch { /* no meta file */ }
187
+ } catch { /* no meta file */
188
+ }
188
189
  blocks.push({
189
190
  name,
190
191
  size: fileStat?.size ?? 0,
@@ -210,7 +211,8 @@ export async function getBlock(name) {
210
211
  try {
211
212
  const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
212
213
  bundled = !!meta.bundled;
213
- } catch { /* no meta file */ }
214
+ } catch { /* no meta file */
215
+ }
214
216
  return {name, content, bundled};
215
217
  } catch (err) {
216
218
  if (err.code === 'ENOENT') {
@@ -238,7 +240,8 @@ export async function saveBlock(name, content, {bundled} = {}) {
238
240
  if (bundled) {
239
241
  await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
240
242
  } else {
241
- await fs.unlink(metaPath).catch(() => {});
243
+ await fs.unlink(metaPath).catch(() => {
244
+ });
242
245
  }
243
246
  return {success: true, name};
244
247
  }