domma-cms 0.23.0 → 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 (38) hide show
  1. package/CLAUDE.md +9 -0
  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/role-editor.html +70 -70
  12. package/admin/js/templates/roles.html +10 -10
  13. package/admin/js/views/api-tokens.js +8 -0
  14. package/admin/js/views/collection-editor.js +4 -4
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/roles.js +1 -1
  17. package/bin/lib/config-merge.js +44 -44
  18. package/bin/update.js +547 -547
  19. package/config/menus/admin-sidebar.json +7 -1
  20. package/package.json +1 -1
  21. package/server/middleware/auth.js +253 -253
  22. package/server/routes/api/api-tokens.js +83 -0
  23. package/server/routes/api/auth.js +309 -309
  24. package/server/routes/api/collections.js +113 -16
  25. package/server/routes/api/navigation.js +42 -42
  26. package/server/routes/api/settings.js +141 -141
  27. package/server/routes/public.js +202 -202
  28. package/server/server.js +8 -1
  29. package/server/services/apiTokens.js +259 -0
  30. package/server/services/email.js +167 -167
  31. package/server/services/permissionRegistry.js +13 -0
  32. package/server/services/presetCollections.js +25 -0
  33. package/server/services/roles.js +16 -0
  34. package/server/services/scaffolder.js +31 -1
  35. package/server/services/sidebar-migration.js +44 -0
  36. package/server/services/userProfiles.js +199 -199
  37. package/server/services/users.js +302 -302
  38. 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
@@ -202,8 +202,13 @@ try {
202
202
  }
203
203
 
204
204
  try {
205
- const {runMigration: runSidebarMigration} = await import('./services/sidebar-migration.js');
205
+ const {runMigration: runSidebarMigration, ensureSidebarItem} = await import('./services/sidebar-migration.js');
206
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
+ });
207
212
  } catch (err) {
208
213
  app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
209
214
  }
@@ -279,6 +284,7 @@ const { navigationRoutes } = await import('./routes/api/navigation.js');
279
284
  const {menusRoutes} = await import('./routes/api/menus.js');
280
285
  const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
281
286
  const {projectsRoutes} = await import('./routes/api/projects.js');
287
+ const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
282
288
  const {sidebarRoutes} = await import('./routes/api/sidebar.js');
283
289
  const { mediaRoutes } = await import('./routes/api/media.js');
284
290
  const { usersRoutes } = await import('./routes/api/users.js');
@@ -304,6 +310,7 @@ await app.register(navigationRoutes, { prefix: '/api' });
304
310
  await app.register(menusRoutes, {prefix: '/api'});
305
311
  await app.register(menuLocationsRoutes, {prefix: '/api'});
306
312
  await app.register(projectsRoutes, {prefix: '/api'});
313
+ await app.register(apiTokensRoutes, {prefix: '/api'});
307
314
  await app.register(sidebarRoutes, {prefix: '/api'});
308
315
  await app.register(mediaRoutes, { prefix: '/api' });
309
316
  await app.register(usersRoutes, { prefix: '/api' });