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.
- package/CLAUDE.md +9 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/templates/api-tokens.html +13 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-submissions.html +30 -30
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-tokens.js +8 -0
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/roles.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +547 -547
- package/config/menus/admin-sidebar.json +7 -1
- package/package.json +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +113 -16
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +8 -1
- package/server/services/apiTokens.js +259 -0
- package/server/services/email.js +167 -167
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/presetCollections.js +25 -0
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +31 -1
- package/server/services/sidebar-migration.js +44 -0
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/config/connections.json.bak +0 -9
package/server/routes/public.js
CHANGED
|
@@ -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, '<')
|
|
46
|
-
.replace(/>/g, '>')
|
|
47
|
-
.replace(/"/g, '"')
|
|
48
|
-
.replace(/'/g, ''');
|
|
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, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, ''');
|
|
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' });
|