domma-cms 0.22.6 → 0.24.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 (45) hide show
  1. package/CLAUDE.md +16 -5
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/crud-tutorial.js +1 -1
  5. package/admin/js/lib/project-context.js +1 -1
  6. package/admin/js/templates/api-tokens.html +13 -0
  7. package/admin/js/templates/effects.html +752 -752
  8. package/admin/js/templates/form-submissions.html +30 -30
  9. package/admin/js/templates/forms.html +17 -17
  10. package/admin/js/templates/my-profile.html +17 -17
  11. package/admin/js/templates/project-settings.html +1 -1
  12. package/admin/js/templates/role-editor.html +70 -70
  13. package/admin/js/templates/roles.html +10 -10
  14. package/admin/js/views/api-tokens.js +8 -0
  15. package/admin/js/views/collection-editor.js +4 -4
  16. package/admin/js/views/index.js +1 -1
  17. package/admin/js/views/project-settings.js +1 -1
  18. package/admin/js/views/projects.js +3 -3
  19. package/admin/js/views/roles.js +1 -1
  20. package/bin/lib/config-merge.js +44 -44
  21. package/bin/update.js +547 -547
  22. package/config/menus/admin-sidebar.json +7 -1
  23. package/package.json +3 -2
  24. package/server/middleware/auth.js +253 -253
  25. package/server/routes/api/api-tokens.js +83 -0
  26. package/server/routes/api/auth.js +309 -309
  27. package/server/routes/api/collections.js +113 -16
  28. package/server/routes/api/forms.js +765 -746
  29. package/server/routes/api/navigation.js +42 -42
  30. package/server/routes/api/projects.js +9 -2
  31. package/server/routes/api/settings.js +141 -141
  32. package/server/routes/public.js +202 -202
  33. package/server/server.js +10 -1
  34. package/server/services/apiTokens.js +259 -0
  35. package/server/services/email.js +167 -167
  36. package/server/services/forms.js +345 -255
  37. package/server/services/permissionRegistry.js +13 -0
  38. package/server/services/presetCollections.js +27 -1
  39. package/server/services/projects.js +115 -24
  40. package/server/services/roles.js +16 -0
  41. package/server/services/scaffolder.js +31 -1
  42. package/server/services/sidebar-migration.js +44 -0
  43. package/server/services/userProfiles.js +199 -199
  44. package/server/services/users.js +302 -302
  45. package/config/connections.json.bak +0 -9
@@ -1,202 +1,202 @@
1
- /**
2
- * Public Site Routes
3
- * Catch-all that resolves URL paths to Markdown pages and renders them server-side.
4
- * Draft pages are not served publicly.
5
- * The admin panel is excluded (handled by static serving).
6
- */
7
- import {getPage} from '../services/content.js';
8
- import {renderPage} from '../services/renderer.js';
9
- import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
10
- import {checkVisibility} from '../middleware/auth.js';
11
- import {hooks} from '../services/hooks.js';
12
- import {getConfig} from '../config.js';
13
- import * as cache from '../services/cache/index.js';
14
-
15
- /**
16
- * Derive the absolute origin for the current request.
17
- *
18
- * Honours `site.baseUrl` as an explicit override (useful when the CMS sits
19
- * behind infrastructure that doesn't forward Host correctly). Otherwise
20
- * builds it from `request.protocol` + `request.host` — both of which Fastify
21
- * resolves from `X-Forwarded-*` headers when `trustProxy` is on. `request.host`
22
- * includes the port for non-default ports (e.g. `localhost:4096` in dev) and
23
- * omits it for the standard 80/443.
24
- *
25
- * @param {import('fastify').FastifyRequest} request
26
- * @returns {string} e.g. 'https://example.com' (no trailing slash)
27
- */
28
- function getBaseUrl(request) {
29
- const override = (getConfig('site') || {}).baseUrl;
30
- if (override) return override.toString().trim().replace(/\/+$/, '');
31
- const host = request.host || request.headers.host || request.hostname;
32
- return `${request.protocol}://${host}`;
33
- }
34
-
35
- /**
36
- * Escape user-controlled strings before interpolating into HTML.
37
- * Prevents reflected XSS in error pages.
38
- *
39
- * @param {string} str
40
- * @returns {string}
41
- */
42
- function escapeHtml(str) {
43
- return String(str)
44
- .replace(/&/g, '&')
45
- .replace(/</g, '&lt;')
46
- .replace(/>/g, '&gt;')
47
- .replace(/"/g, '&quot;')
48
- .replace(/'/g, '&#39;');
49
- }
50
-
51
- export async function publicRoutes(fastify) {
52
- // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
53
- fastify.get('/admin', async (request, reply) => {
54
- return reply.redirect('/admin/');
55
- });
56
-
57
- // Health check
58
- fastify.get('/api/health', async () => ({ status: 'ok' }));
59
-
60
- // SEO: sitemap.xml — cached per origin (so a single CMS responding on
61
- // multiple domains gets the right absolute URLs); invalidated by page
62
- // CRUD via the 'sitemap' tag.
63
- fastify.get('/sitemap.xml', async (request, reply) => {
64
- const baseUrl = getBaseUrl(request);
65
- const xml = await cache.wrap(
66
- `sitemap:xml:${baseUrl}`,
67
- () => generateSitemap(baseUrl),
68
- {tags: ['sitemap']}
69
- );
70
- return reply.type('application/xml').send(xml);
71
- });
72
-
73
- // SEO: robots.txt — cheap to build, no cache wrap needed.
74
- fastify.get('/robots.txt', async (request, reply) => {
75
- return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
76
- });
77
-
78
- // Public pages catch-all
79
- fastify.get('/*', async (request, reply) => {
80
- const rawPath = request.params['*'];
81
-
82
- // Skip non-page paths (assets handled by static plugin)
83
- if (rawPath.includes('.')) {
84
- return reply.callNotFound();
85
- }
86
-
87
- const urlPath = '/' + (rawPath || '');
88
-
89
- // First-pass page lookup — used only for the visibility decision
90
- // (metadata-only is sufficient). We re-fetch below with `user` once
91
- // it is resolved so the body's [menu] shortcode renders against the
92
- // correct role.
93
- let page = await getPage(urlPath);
94
-
95
- // Try fetching index of a directory path
96
- if (!page && !urlPath.endsWith('/index')) {
97
- page = await getPage(urlPath.replace(/\/$/, '') || '/');
98
- }
99
-
100
- if (!page) {
101
- reply.status(404);
102
- return reply.type('text/html').send(await render404(urlPath));
103
- }
104
-
105
- // Don't serve draft pages publicly
106
- if (page.status !== 'published') {
107
- reply.status(404);
108
- return reply.type('text/html').send(await render404(urlPath));
109
- }
110
-
111
- // Enforce page visibility — role only resolved for gated pages,
112
- // so public pages share a single cache entry keyed `roleanon`.
113
- //
114
- // `visibility` may be a string ('public' | 'private' | role name) or
115
- // an array of role names — see checkVisibility() for full semantics.
116
- // Effectively-public values (missing, 'public', or an array containing
117
- // 'public') skip JWT verification entirely so anonymous traffic hits
118
- // the shared cache without auth cost.
119
- // For per-role cache keying we use the primary role only — multi-role
120
- // users still see correctly-gated content (checkVisibility consults
121
- // additionalRoles too) but the cache key stays bounded to one entry
122
- // per primary role rather than 2^N per role combination. The fallback
123
- // is correctness: any user whose access depends on an additional role
124
- // is granted, but their served HTML is the variant cached for their
125
- // primary role's tier — fine for the public-site use case.
126
- let userRole = null;
127
- let userObj = null;
128
- const vis = page.visibility;
129
- const isPublic = !vis
130
- || vis === 'public'
131
- || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
132
-
133
- if (!isPublic) {
134
- try {
135
- const decoded = await request.jwtVerify();
136
- if (decoded.type === 'access') {
137
- userRole = decoded.role;
138
- userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
139
- }
140
- } catch { /* no token — treat as unauthenticated */ }
141
-
142
- if (!checkVisibility(userObj, vis)) {
143
- reply.status(403);
144
- return reply.type('text/html').send(accessDeniedHtml(urlPath));
145
- }
146
- }
147
-
148
- const baseUrl = getBaseUrl(request);
149
- const cacheKey = `page:${urlPath}:role${userRole ?? 'anon'}:o${baseUrl}`;
150
- const cacheTags = [`page:${urlPath}`, ...(page.tags || []), 'nav', 'site'];
151
- const html = await cache.wrap(
152
- cacheKey,
153
- async () => {
154
- // Re-parse with user context so the body's [menu] shortcode
155
- // sees the visitor's role for visibility filtering. The first
156
- // getPage() above was anonymous because we hadn't decoded the
157
- // JWT yet; this second pass uses the resolved userObj.
158
- const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
159
- return renderPage(pageForRole, {baseUrl, user: userObj});
160
- },
161
- {tags: cacheTags}
162
- );
163
- hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
164
- return reply.type('text/html').send(html);
165
- });
166
- }
167
-
168
- /**
169
- * Render a 404 response — tries content/pages/404.md first, falls back to
170
- * a minimal inline page so the site theme is applied when possible.
171
- */
172
- async function render404(urlPath) {
173
- try {
174
- const page404 = await getPage('/404');
175
- if (page404 && page404.status === 'published') {
176
- return await renderPage(page404);
177
- }
178
- } catch { /* fall through */ }
179
- return notFoundHtml(urlPath);
180
- }
181
-
182
- function accessDeniedHtml(urlPath) {
183
- return `<!DOCTYPE html>
184
- <html lang="en-GB">
185
- <head><meta charset="UTF-8"><title>403 Access Denied</title>
186
- <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;}
187
- .box{text-align:center;} h1{font-size:6rem;margin:0;color:#666;} p{color:#999;}</style>
188
- </head>
189
- <body><div class="box"><h1>403</h1><p>You don't have permission to view <code>${escapeHtml(urlPath)}</code></p><p><a href="/" style="color:#aaa">Go home</a></p></div></body>
190
- </html>`;
191
- }
192
-
193
- function notFoundHtml(urlPath) {
194
- return `<!DOCTYPE html>
195
- <html lang="en-GB">
196
- <head><meta charset="UTF-8"><title>404 Not Found</title>
197
- <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;}
198
- .box{text-align:center;} h1{font-size:6rem;margin:0;color:#666;} p{color:#999;}</style>
199
- </head>
200
- <body><div class="box"><h1>404</h1><p>No page found at <code>${escapeHtml(urlPath)}</code></p><p><a href="/" style="color:#aaa">Go home</a></p></div></body>
201
- </html>`;
202
- }
1
+ /**
2
+ * Public Site Routes
3
+ * Catch-all that resolves URL paths to Markdown pages and renders them server-side.
4
+ * Draft pages are not served publicly.
5
+ * The admin panel is excluded (handled by static serving).
6
+ */
7
+ import {getPage} from '../services/content.js';
8
+ import {renderPage} from '../services/renderer.js';
9
+ import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
10
+ import {checkVisibility} from '../middleware/auth.js';
11
+ import {hooks} from '../services/hooks.js';
12
+ import {getConfig} from '../config.js';
13
+ import * as cache from '../services/cache/index.js';
14
+
15
+ /**
16
+ * Derive the absolute origin for the current request.
17
+ *
18
+ * Honours `site.baseUrl` as an explicit override (useful when the CMS sits
19
+ * behind infrastructure that doesn't forward Host correctly). Otherwise
20
+ * builds it from `request.protocol` + `request.host` — both of which Fastify
21
+ * resolves from `X-Forwarded-*` headers when `trustProxy` is on. `request.host`
22
+ * includes the port for non-default ports (e.g. `localhost:4096` in dev) and
23
+ * omits it for the standard 80/443.
24
+ *
25
+ * @param {import('fastify').FastifyRequest} request
26
+ * @returns {string} e.g. 'https://example.com' (no trailing slash)
27
+ */
28
+ function getBaseUrl(request) {
29
+ const override = (getConfig('site') || {}).baseUrl;
30
+ if (override) return override.toString().trim().replace(/\/+$/, '');
31
+ const host = request.host || request.headers.host || request.hostname;
32
+ return `${request.protocol}://${host}`;
33
+ }
34
+
35
+ /**
36
+ * Escape user-controlled strings before interpolating into HTML.
37
+ * Prevents reflected XSS in error pages.
38
+ *
39
+ * @param {string} str
40
+ * @returns {string}
41
+ */
42
+ function escapeHtml(str) {
43
+ return String(str)
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&#39;');
49
+ }
50
+
51
+ export async function publicRoutes(fastify) {
52
+ // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
53
+ fastify.get('/admin', async (request, reply) => {
54
+ return reply.redirect('/admin/');
55
+ });
56
+
57
+ // Health check
58
+ fastify.get('/api/health', async () => ({ status: 'ok' }));
59
+
60
+ // SEO: sitemap.xml — cached per origin (so a single CMS responding on
61
+ // multiple domains gets the right absolute URLs); invalidated by page
62
+ // CRUD via the 'sitemap' tag.
63
+ fastify.get('/sitemap.xml', async (request, reply) => {
64
+ const baseUrl = getBaseUrl(request);
65
+ const xml = await cache.wrap(
66
+ `sitemap:xml:${baseUrl}`,
67
+ () => generateSitemap(baseUrl),
68
+ {tags: ['sitemap']}
69
+ );
70
+ return reply.type('application/xml').send(xml);
71
+ });
72
+
73
+ // SEO: robots.txt — cheap to build, no cache wrap needed.
74
+ fastify.get('/robots.txt', async (request, reply) => {
75
+ return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
76
+ });
77
+
78
+ // Public pages catch-all
79
+ fastify.get('/*', async (request, reply) => {
80
+ const rawPath = request.params['*'];
81
+
82
+ // Skip non-page paths (assets handled by static plugin)
83
+ if (rawPath.includes('.')) {
84
+ return reply.callNotFound();
85
+ }
86
+
87
+ const urlPath = '/' + (rawPath || '');
88
+
89
+ // First-pass page lookup — used only for the visibility decision
90
+ // (metadata-only is sufficient). We re-fetch below with `user` once
91
+ // it is resolved so the body's [menu] shortcode renders against the
92
+ // correct role.
93
+ let page = await getPage(urlPath);
94
+
95
+ // Try fetching index of a directory path
96
+ if (!page && !urlPath.endsWith('/index')) {
97
+ page = await getPage(urlPath.replace(/\/$/, '') || '/');
98
+ }
99
+
100
+ if (!page) {
101
+ reply.status(404);
102
+ return reply.type('text/html').send(await render404(urlPath));
103
+ }
104
+
105
+ // Don't serve draft pages publicly
106
+ if (page.status !== 'published') {
107
+ reply.status(404);
108
+ return reply.type('text/html').send(await render404(urlPath));
109
+ }
110
+
111
+ // Enforce page visibility — role only resolved for gated pages,
112
+ // so public pages share a single cache entry keyed `roleanon`.
113
+ //
114
+ // `visibility` may be a string ('public' | 'private' | role name) or
115
+ // an array of role names — see checkVisibility() for full semantics.
116
+ // Effectively-public values (missing, 'public', or an array containing
117
+ // 'public') skip JWT verification entirely so anonymous traffic hits
118
+ // the shared cache without auth cost.
119
+ // For per-role cache keying we use the primary role only — multi-role
120
+ // users still see correctly-gated content (checkVisibility consults
121
+ // additionalRoles too) but the cache key stays bounded to one entry
122
+ // per primary role rather than 2^N per role combination. The fallback
123
+ // is correctness: any user whose access depends on an additional role
124
+ // is granted, but their served HTML is the variant cached for their
125
+ // primary role's tier — fine for the public-site use case.
126
+ let userRole = null;
127
+ let userObj = null;
128
+ const vis = page.visibility;
129
+ const isPublic = !vis
130
+ || vis === 'public'
131
+ || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
132
+
133
+ if (!isPublic) {
134
+ try {
135
+ const decoded = await request.jwtVerify();
136
+ if (decoded.type === 'access') {
137
+ userRole = decoded.role;
138
+ userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
139
+ }
140
+ } catch { /* no token — treat as unauthenticated */ }
141
+
142
+ if (!checkVisibility(userObj, vis)) {
143
+ reply.status(403);
144
+ return reply.type('text/html').send(accessDeniedHtml(urlPath));
145
+ }
146
+ }
147
+
148
+ const baseUrl = getBaseUrl(request);
149
+ const cacheKey = `page:${urlPath}:role${userRole ?? 'anon'}:o${baseUrl}`;
150
+ const cacheTags = [`page:${urlPath}`, ...(page.tags || []), 'nav', 'site'];
151
+ const html = await cache.wrap(
152
+ cacheKey,
153
+ async () => {
154
+ // Re-parse with user context so the body's [menu] shortcode
155
+ // sees the visitor's role for visibility filtering. The first
156
+ // getPage() above was anonymous because we hadn't decoded the
157
+ // JWT yet; this second pass uses the resolved userObj.
158
+ const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
159
+ return renderPage(pageForRole, {baseUrl, user: userObj});
160
+ },
161
+ {tags: cacheTags}
162
+ );
163
+ hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
164
+ return reply.type('text/html').send(html);
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Render a 404 response — tries content/pages/404.md first, falls back to
170
+ * a minimal inline page so the site theme is applied when possible.
171
+ */
172
+ async function render404(urlPath) {
173
+ try {
174
+ const page404 = await getPage('/404');
175
+ if (page404 && page404.status === 'published') {
176
+ return await renderPage(page404);
177
+ }
178
+ } catch { /* fall through */ }
179
+ return notFoundHtml(urlPath);
180
+ }
181
+
182
+ function accessDeniedHtml(urlPath) {
183
+ return `<!DOCTYPE html>
184
+ <html lang="en-GB">
185
+ <head><meta charset="UTF-8"><title>403 Access Denied</title>
186
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;}
187
+ .box{text-align:center;} h1{font-size:6rem;margin:0;color:#666;} p{color:#999;}</style>
188
+ </head>
189
+ <body><div class="box"><h1>403</h1><p>You don't have permission to view <code>${escapeHtml(urlPath)}</code></p><p><a href="/" style="color:#aaa">Go home</a></p></div></body>
190
+ </html>`;
191
+ }
192
+
193
+ function notFoundHtml(urlPath) {
194
+ return `<!DOCTYPE html>
195
+ <html lang="en-GB">
196
+ <head><meta charset="UTF-8"><title>404 Not Found</title>
197
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#eee;}
198
+ .box{text-align:center;} h1{font-size:6rem;margin:0;color:#666;} p{color:#999;}</style>
199
+ </head>
200
+ <body><div class="box"><h1>404</h1><p>No page found at <code>${escapeHtml(urlPath)}</code></p><p><a href="/" style="color:#aaa">Go home</a></p></div></body>
201
+ </html>`;
202
+ }
package/server/server.js CHANGED
@@ -22,6 +22,7 @@ import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/p
22
22
  import {load as loadRoles, seed as seedRoles} from './services/roles.js';
23
23
  import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
24
24
  import {seedAll as seedPresetCollections} from './services/presetCollections.js';
25
+ import {seedCoreProject} from './services/projects.js';
25
26
  import {seedDefaultBlocks} from './services/blocks.js';
26
27
  import {seedDefaultComponents} from './services/components.js';
27
28
  import {refreshComponentTagAllowlist} from './services/markdown.js';
@@ -184,6 +185,7 @@ await loadRoles();
184
185
  await seedUserProfiles();
185
186
  await ensureAllProfiles();
186
187
  await seedPresetCollections();
188
+ await seedCoreProject();
187
189
  await seedDefaultBlocks();
188
190
  await seedDefaultComponents();
189
191
  await refreshComponentTagAllowlist();
@@ -200,8 +202,13 @@ try {
200
202
  }
201
203
 
202
204
  try {
203
- const {runMigration: runSidebarMigration} = await import('./services/sidebar-migration.js');
205
+ const {runMigration: runSidebarMigration, ensureSidebarItem} = await import('./services/sidebar-migration.js');
204
206
  await runSidebarMigration();
207
+ // Surface admin pages added after first boot on existing installs.
208
+ await ensureSidebarItem({
209
+ groupText: 'System',
210
+ item: {text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'}
211
+ });
205
212
  } catch (err) {
206
213
  app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
207
214
  }
@@ -277,6 +284,7 @@ const { navigationRoutes } = await import('./routes/api/navigation.js');
277
284
  const {menusRoutes} = await import('./routes/api/menus.js');
278
285
  const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
279
286
  const {projectsRoutes} = await import('./routes/api/projects.js');
287
+ const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
280
288
  const {sidebarRoutes} = await import('./routes/api/sidebar.js');
281
289
  const { mediaRoutes } = await import('./routes/api/media.js');
282
290
  const { usersRoutes } = await import('./routes/api/users.js');
@@ -302,6 +310,7 @@ await app.register(navigationRoutes, { prefix: '/api' });
302
310
  await app.register(menusRoutes, {prefix: '/api'});
303
311
  await app.register(menuLocationsRoutes, {prefix: '/api'});
304
312
  await app.register(projectsRoutes, {prefix: '/api'});
313
+ await app.register(apiTokensRoutes, {prefix: '/api'});
305
314
  await app.register(sidebarRoutes, {prefix: '/api'});
306
315
  await app.register(mediaRoutes, { prefix: '/api' });
307
316
  await app.register(usersRoutes, { prefix: '/api' });