domma-cms 0.70.1 → 0.72.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 (48) hide show
  1. package/CLAUDE.md +24 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/app.js +5 -5
  6. package/admin/js/lib/activity-throbber.js +1 -0
  7. package/admin/js/lib/admin-scope.js +1 -0
  8. package/admin/js/lib/menu-rows.js +1 -0
  9. package/admin/js/lib/plugin-chrome.js +1 -1
  10. package/admin/js/lib/sidebar-grouping.js +1 -1
  11. package/admin/js/lib/sidebar-renderer.js +4 -4
  12. package/admin/js/templates/page-editor.html +1 -4
  13. package/admin/js/templates/role-editor.html +28 -0
  14. package/admin/js/templates/settings.html +8 -0
  15. package/admin/js/views/menu-editor.js +16 -16
  16. package/admin/js/views/page-editor.js +26 -26
  17. package/admin/js/views/role-editor.js +3 -1
  18. package/admin/js/views/settings.js +3 -3
  19. package/package.json +2 -2
  20. package/plugins/_template/plugin.js +5 -2
  21. package/plugins/blog/CLAUDE.md +20 -5
  22. package/plugins/blog/admin/views/kit.js +8 -10
  23. package/plugins/blog/lib/layouts.js +36 -0
  24. package/plugins/blog/lib/nav-link.js +22 -0
  25. package/plugins/blog/lib/page.js +13 -1
  26. package/plugins/blog/plugin.js +28 -9
  27. package/plugins/blog/plugin.json +6 -6
  28. package/plugins/blog/plugin.public.js +102 -80
  29. package/plugins/blog/tests/layouts.test.js +29 -0
  30. package/plugins/blog/tests/nav-link.test.js +10 -1
  31. package/plugins/blog/tests/public.test.js +21 -0
  32. package/plugins/free-tier.lock.json +13 -13
  33. package/server/middleware/auth.js +21 -0
  34. package/server/routes/api/auth.js +29 -8
  35. package/server/routes/api/collections.js +40 -3
  36. package/server/routes/api/settings.js +9 -0
  37. package/server/routes/api/sidebar.js +31 -10
  38. package/server/routes/public.js +13 -17
  39. package/server/server.js +10 -8
  40. package/server/services/adminHome.js +87 -0
  41. package/server/services/hooks.js +30 -6
  42. package/server/services/menus.js +36 -6
  43. package/server/services/permissionRegistry.js +10 -7
  44. package/server/services/pluginInstaller.js +23 -9
  45. package/server/services/plugins.js +333 -45
  46. package/server/services/roles.js +181 -5
  47. package/server/services/sidebar-migration.js +82 -8
  48. package/admin/js/lib/sidebar-grouping.test.js +0 -1
@@ -7,19 +7,23 @@
7
7
  * over - search, category pills, sort, display switching and a right-click
8
8
  * menu - and adds reading progress, contents tracking, sharing and in-page
9
9
  * comments to a post.
10
+ *
11
+ * The pages are not fixed routes: one onRequest hook matches the path against
12
+ * the base path as it is saved NOW, so changing it in Settings moves the blog
13
+ * without a restart. See the Gotchas in CLAUDE.md.
10
14
  */
11
15
  import path from 'path';
12
16
  import {fileURLToPath} from 'url';
13
17
  import {renderBlogPage} from '../../server/services/renderer.js';
14
- import {byNewest, escapeHtml, pickDisplay, safeImageUrl} from './lib/layouts.js';
18
+ import {byNewest, escapeHtml, matchBlogPath, pickDisplay, safeImageUrl} from './lib/layouts.js';
15
19
  import {catalogue, renderCards} from './lib/render.js';
16
- import {authorNamesFor, isVisible, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
20
+ import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
17
21
 
18
22
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
19
23
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
20
24
 
21
25
  /** Bumped with the plugin, so a release is never served a stale script. */
22
- const ASSET_V = '1.6.0';
26
+ const ASSET_V = '1.6.3';
23
27
 
24
28
  /**
25
29
  * Above this many posts a listing pages on the server instead: the browser
@@ -56,7 +60,6 @@ function scriptJson(value) {
56
60
 
57
61
  export default async function blogPublicPlugin(fastify, options) {
58
62
  const {settings, config} = options;
59
- const base = (settings?.basePath ?? '/blog').replace(/\/$/, '');
60
63
 
61
64
  /**
62
65
  * A listing: the index, a page of it, or a category / tag / author archive.
@@ -64,9 +67,9 @@ export default async function blogPublicPlugin(fastify, options) {
64
67
  * @param {object} request
65
68
  * @param {object} reply
66
69
  * @param {object[]} posts - candidates; drafts are dropped here
67
- * @param {object} opts - {title, intro, page, pageBase, filter}
70
+ * @param {object} opts - {base, title, intro, page, filter}
68
71
  */
69
- async function renderArchive(request, reply, posts, {title, intro = '', page = 1, pageBase = base, filter = null}) {
72
+ async function renderArchive(request, reply, posts, {base, title, intro = '', page = 1, filter = null}) {
70
73
  const s = liveSettings(settings);
71
74
  const perPage = Math.max(1, Number(s.postsPerPage) || 10);
72
75
  const {listDisplays} = await catalogue();
@@ -115,7 +118,7 @@ ${items || ''}
115
118
  archiveTitle: escapeHtml(title),
116
119
  archiveIntro: intro ? `<p class="blog-archive-intro">${escapeHtml(intro)}</p>` : '',
117
120
  archive,
118
- pagination: interactive ? '' : buildPagination(safePage, totalPages, pageBase, requested),
121
+ pagination: interactive ? '' : buildPagination(safePage, totalPages, base, requested),
119
122
  assetV: ASSET_V
120
123
  },
121
124
  {title, urlPath: request.url.split('?')[0]}
@@ -123,16 +126,18 @@ ${items || ''}
123
126
  return reply.type('text/html').send(html);
124
127
  }
125
128
 
126
- // 1. RSS feed
127
- fastify.get(`${base}/feed.xml`, async (request, reply) => {
128
- const s = liveSettings(settings);
129
- const allPosts = await loadEntries('blog-posts');
130
- const posts = allPosts.filter(isVisible).sort(byNewest).slice(0, s.feedLength ?? 20);
129
+ /** One handler per page; `matchBlogPath()` (lib/layouts.js) picks which. */
130
+ const pages = {
131
+ /** RSS feed */
132
+ async feed(request, reply, params, base) {
133
+ const s = liveSettings(settings);
134
+ const allPosts = await loadEntries('blog-posts');
135
+ const posts = allPosts.filter(isVisible).sort(byNewest).slice(0, s.feedLength ?? 20);
131
136
 
132
- const siteTitle = config?.site?.title ?? 'Blog';
133
- const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
137
+ const siteTitle = config?.site?.title ?? 'Blog';
138
+ const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
134
139
 
135
- const items = posts.map((post) => ` <item>
140
+ const items = posts.map((post) => ` <item>
136
141
  <title>${escapeHtml(post.title)}</title>
137
142
  <link>${siteUrl}${base}/${escapeHtml(post.slug)}</link>
138
143
  <description>${escapeHtml(post.excerpt || '')}</description>
@@ -140,7 +145,7 @@ ${items || ''}
140
145
  <guid>${siteUrl}${base}/${escapeHtml(post.slug)}</guid>
141
146
  </item>`).join('\n');
142
147
 
143
- return reply.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
148
+ return reply.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
144
149
  <rss version="2.0">
145
150
  <channel>
146
151
  <title>${escapeHtml(siteTitle)}</title>
@@ -149,69 +154,86 @@ ${items || ''}
149
154
  ${items}
150
155
  </channel>
151
156
  </rss>`);
157
+ },
158
+
159
+ /** Paginated index */
160
+ async page(request, reply, {n}, base) {
161
+ const page = parseInt(n, 10) || 1;
162
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {base, title: 'Blog', page});
163
+ },
164
+
165
+ /** Category archive */
166
+ async category(request, reply, {slug}, base) {
167
+ const [allPosts, allCategories] = await Promise.all([loadEntries('blog-posts'), loadEntries('blog-categories')]);
168
+ const category = allCategories.find((c) => c.slug === slug);
169
+ if (!category) return reply.code(404).send('Category not found');
170
+ const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
171
+ return renderArchive(request, reply, posts, {
172
+ base, title: category.name, intro: category.description || '', filter: {type: 'category', value: category.slug}
173
+ });
174
+ },
175
+
176
+ /** Tag archive */
177
+ async tag(request, reply, {tag}, base) {
178
+ const posts = (await loadEntries('blog-posts')).filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
179
+ return renderArchive(request, reply, posts, {base, title: `#${tag}`, filter: {type: 'tag', value: tag}});
180
+ },
181
+
182
+ /** Author archive */
183
+ async author(request, reply, {id}, base) {
184
+ const posts = (await loadEntries('blog-posts')).filter((p) => p.authorId === id);
185
+ const authorName = await resolveAuthorName(id);
186
+ return renderArchive(request, reply, posts, {base, title: `Posts by ${authorName}`, filter: {type: 'author', value: id}});
187
+ },
188
+
189
+ /** Blog index */
190
+ async index(request, reply, params, base) {
191
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {base, title: 'Blog'});
192
+ },
193
+
194
+ /** `{base}/` - the index, rather than a post with an empty slug */
195
+ async slash(request, reply, params, base) {
196
+ const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
197
+ return reply.redirect(`${base}${query}`, 301);
198
+ },
199
+
200
+ /** Individual post */
201
+ async post(request, reply, {slug}, base) {
202
+ const allPosts = await loadEntries('blog-posts');
203
+ const post = allPosts.find((p) => p.slug === slug);
204
+ if (!post || !isVisible(post)) return reply.code(404).send('Not found');
205
+
206
+ const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
207
+ const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
208
+
209
+ const html = await renderBlogPage(
210
+ path.join(TEMPLATES_DIR, 'post.html'),
211
+ {post: page.html, assetV: ASSET_V},
212
+ {
213
+ title: post.seo?.title || post.title,
214
+ description: post.seo?.description || post.excerpt || '',
215
+ ogImage: safeImageUrl(post.featuredImage),
216
+ ogType: 'article',
217
+ usedComponents: page.usedComponents,
218
+ urlPath: `${base}/${slug}`
219
+ }
220
+ );
221
+ return reply.type('text/html').send(html);
222
+ }
223
+ };
224
+
225
+ // Answered here, ahead of routing, because routes are fixed at boot and the
226
+ // base path is not. The hook is global (skip-override, below); it answers
227
+ // only what matchBlogPath() claims and lets everything else through.
228
+ fastify.addHook('onRequest', async (request, reply) => {
229
+ if (request.method !== 'GET' && request.method !== 'HEAD') return;
230
+ const base = liveBase(settings);
231
+ const hit = matchBlogPath(request.url.split('?')[0], base);
232
+ if (!hit) return;
233
+ return pages[hit.name](request, reply, hit.params, base);
152
234
  });
153
-
154
- // 2. Paginated index
155
- fastify.get(`${base}/page/:n`, async (request, reply) => {
156
- const page = parseInt(request.params.n, 10) || 1;
157
- return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog', page});
158
- });
159
-
160
- // 3. Category archive
161
- fastify.get(`${base}/category/:slug`, async (request, reply) => {
162
- const {slug} = request.params;
163
- const [allPosts, allCategories] = await Promise.all([loadEntries('blog-posts'), loadEntries('blog-categories')]);
164
- const category = allCategories.find((c) => c.slug === slug);
165
- if (!category) return reply.code(404).send('Category not found');
166
- const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
167
- return renderArchive(request, reply, posts, {
168
- title: category.name, intro: category.description || '', filter: {type: 'category', value: category.slug}
169
- });
170
- });
171
-
172
- // 4. Tag archive
173
- fastify.get(`${base}/tag/:tag`, async (request, reply) => {
174
- const {tag} = request.params;
175
- const posts = (await loadEntries('blog-posts')).filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
176
- return renderArchive(request, reply, posts, {title: `#${tag}`, filter: {type: 'tag', value: tag}});
177
- });
178
-
179
- // 5. Author archive
180
- fastify.get(`${base}/author/:id`, async (request, reply) => {
181
- const {id} = request.params;
182
- const posts = (await loadEntries('blog-posts')).filter((p) => p.authorId === id);
183
- const authorName = await resolveAuthorName(id);
184
- return renderArchive(request, reply, posts, {title: `Posts by ${authorName}`, filter: {type: 'author', value: id}});
185
- });
186
-
187
- // 6. Blog index
188
- fastify.get(base, async (request, reply) => {
189
- return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog'});
190
- });
191
-
192
- // 7. Individual post - registered LAST so the slug match doesn't shadow archives
193
- fastify.get(`${base}/:slug`, async (request, reply) => {
194
- const {slug} = request.params;
195
- const allPosts = await loadEntries('blog-posts');
196
- const post = allPosts.find((p) => p.slug === slug);
197
- if (!post || !isVisible(post)) return reply.code(404).send('Not found');
198
-
199
- const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
200
- const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
201
-
202
- const html = await renderBlogPage(
203
- path.join(TEMPLATES_DIR, 'post.html'),
204
- {post: page.html, assetV: ASSET_V},
205
- {
206
- title: post.seo?.title || post.title,
207
- description: post.seo?.description || post.excerpt || '',
208
- ogImage: safeImageUrl(post.featuredImage),
209
- ogType: 'article',
210
- usedComponents: page.usedComponents,
211
- urlPath: `${base}/${slug}`
212
- }
213
- );
214
- return reply.type('text/html').send(html);
215
- });
216
-
217
235
  }
236
+
237
+ // What fastify-plugin sets: the onRequest hook must see every request, not only
238
+ // routes registered inside this plugin (it registers none).
239
+ blogPublicPlugin[Symbol.for('skip-override')] = true;
@@ -95,3 +95,32 @@ test('related posts: shared categories outweigh shared tags; newer breaks a tie'
95
95
  assert.deepEqual(relatedPosts(me, all, 3).map(p => p.id), ['cat-new', 'cat-old', 'tag-only']);
96
96
  assert.deepEqual(relatedPosts(me, all, 0), []);
97
97
  });
98
+
99
+ test('a base path is tidied, and never the whole site', async () => {
100
+ const {cleanBasePath} = await import('../lib/layouts.js');
101
+ assert.equal(cleanBasePath('/news'), '/news');
102
+ assert.equal(cleanBasePath('news/'), '/news');
103
+ assert.equal(cleanBasePath('//a//b/'), '/a/b');
104
+ assert.equal(cleanBasePath('/x?y=1'), '/x');
105
+ for (const v of ['', '/', ' ', null, undefined]) assert.equal(cleanBasePath(v), '/blog', String(v));
106
+ });
107
+
108
+ test('matchBlogPath claims the blog\'s pages under whatever base it is given', async () => {
109
+ const {matchBlogPath} = await import('../lib/layouts.js');
110
+ assert.deepEqual(matchBlogPath('/news', '/news'), {name: 'index', params: {}});
111
+ assert.deepEqual(matchBlogPath('/news/', '/news'), {name: 'slash', params: {}});
112
+ assert.deepEqual(matchBlogPath('/news/feed.xml', '/news'), {name: 'feed', params: {}});
113
+ assert.deepEqual(matchBlogPath('/news/page/2', '/news'), {name: 'page', params: {n: '2'}});
114
+ assert.deepEqual(matchBlogPath('/news/category/a%20b', '/news'), {name: 'category', params: {slug: 'a b'}});
115
+ assert.deepEqual(matchBlogPath('/news/tag/x', '/news'), {name: 'tag', params: {tag: 'x'}});
116
+ assert.deepEqual(matchBlogPath('/news/author/7', '/news'), {name: 'author', params: {id: '7'}});
117
+ assert.deepEqual(matchBlogPath('/news/my-post', '/news'), {name: 'post', params: {slug: 'my-post'}});
118
+ for (const p of ['/blog', '/newsroom', '/news/a/b', '/news/page/', '/news/%E0%A4%A']) assert.equal(matchBlogPath(p, '/news'), null, p);
119
+ });
120
+
121
+ test('a post Blog Pro filed under another blog is not shown by this one', async () => {
122
+ const {isVisible} = await import('../lib/page.js');
123
+ assert.equal(isVisible({status: 'published'}), true);
124
+ assert.equal(isVisible({status: 'published', blogId: 'main'}), true);
125
+ assert.equal(isVisible({status: 'published', blogId: 'travel'}), false);
126
+ });
@@ -1,6 +1,6 @@
1
1
  import {test} from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import {addNavLink, blogItem, hasLink, removeNavLink, withoutLink} from '../lib/nav-link.js';
3
+ import {addNavLink, blogItem, hasLink, moveNavLink, removeNavLink, withoutLink} from '../lib/nav-link.js';
4
4
 
5
5
  function fakeMenus(items, locations = {navbar: 'main'}) {
6
6
  const store = {main: {slug: 'main', name: 'Main', items}};
@@ -55,3 +55,12 @@ test('no navbar menu mapped: nothing happens', async () => {
55
55
  assert.equal(await addNavLink('/blog', menus), null);
56
56
  assert.deepEqual(withoutLink([{text: 'A', url: '/a'}], '/blog'), [{text: 'A', url: '/a'}]);
57
57
  });
58
+
59
+ test('a new base path moves the link enabling added, and nothing else', async () => {
60
+ const menus = fakeMenus([{text: 'Home', url: '/'}, {text: 'Blog', url: '/blog'}, {text: 'More', url: '#', items: [{text: 'Old', url: '/blog'}]}]);
61
+ assert.equal(await moveNavLink({menu: 'main', url: '/blog'}, '/news/', menus), true);
62
+ assert.deepEqual(menus.store.main.items.map(i => i.url), ['/', '/news', '#']);
63
+ assert.equal(menus.store.main.items[2].items[0].url, '/blog', 'a nested copy is the site\'s own');
64
+ assert.equal(await moveNavLink({menu: 'main', url: '/gone'}, '/x', menus), false);
65
+ assert.equal(await moveNavLink(null, '/x', menus), false);
66
+ });
@@ -69,3 +69,24 @@ test('a post keeps only the fields it has - no authorship, no sample flag, a val
69
69
  assert.equal(postFields({template: '../../etc/passwd'}, {role: 'admin'}).template, '');
70
70
  assert.equal(postFields({template: ''}, {role: 'admin'}).template, '', 'empty is "use the site default"');
71
71
  });
72
+
73
+ test('/blog/ goes to the index, not the post route with an empty slug', async () => {
74
+ const app = await buildPublic();
75
+ const res = await app.inject({method: 'GET', url: '/blog/?display=list'});
76
+ assert.equal(res.statusCode, 301);
77
+ assert.equal(res.headers.location, '/blog?display=list');
78
+ });
79
+
80
+ test('the pages live at the base path in the settings, not a fixed /blog', async () => {
81
+ const app = Fastify();
82
+ await app.register(blogPublicPlugin, {settings: {basePath: '/news'}, config: {}});
83
+ app.get('/*', async () => 'site page');
84
+ const index = await app.inject({method: 'GET', url: '/news'});
85
+ assert.equal(index.statusCode, 200);
86
+ assert.match(index.body, /data-blog-archive/);
87
+ assert.equal((await app.inject({method: 'GET', url: '/news/'})).headers.location, '/news');
88
+ assert.equal((await app.inject({method: 'GET', url: '/news/feed.xml'})).statusCode, 200);
89
+ const other = await app.inject({method: 'GET', url: '/blog'});
90
+ assert.equal(other.body, 'site page', 'the old path is the site\'s again');
91
+ assert.equal((await app.inject({method: 'POST', url: '/news'})).statusCode, 404, 'GET only');
92
+ });
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@cb07e46",
3
+ "source": "dcms-marketplace@d9bfe49",
4
4
  "plugins": {
5
5
  "blog": {
6
- "version": "1.6.0",
6
+ "version": "1.6.3",
7
7
  "files": {
8
- "CLAUDE.md": "155f6e6e09531b8ab87252f942c1c75121a6c10abdebc4316fa7da44edb42642",
8
+ "CLAUDE.md": "63b11d562db0e0003b929320f55f2a6dedf6ba98a20ed6f5c768fa3177286de9",
9
9
  "admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
10
10
  "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
11
  "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
@@ -15,7 +15,7 @@
15
15
  "admin/views/blog.js": "3fc84f7ea9fce352f150771b4d1f775965592a8690ad7fd1110a3a4e72c49c74",
16
16
  "admin/views/categories.js": "75140feb2b382ba0ab4a52c30007845bf576883c99577fc0965783adb8808f6b",
17
17
  "admin/views/comments.js": "7c93f964560b5b02df270bd17526b8f749cc7c8871a9e863bb84e9cbfbc4da0e",
18
- "admin/views/kit.js": "e7a787836090f622d7108f2e31a041bfedd0aa6ffb9c9d0b341daa2640e13e2e",
18
+ "admin/views/kit.js": "91292d63a96c1d1e9fdc0c8f931d0f31e07fa37d06f06e0fc45b308863e126aa",
19
19
  "admin/views/post-editor.js": "6bb0867d3b57d817497115e5c56479332f2a5324ab99aab2fdabc714c888c543",
20
20
  "admin/views/settings-cog.js": "307efc93835863dd8511f8fb17a224cf45a77532c90b854a86d2cd1d4e5f9f18",
21
21
  "admin/views/settings.js": "2b7f775e37e034dee4ae3dfadea5e446c2e526bed8a066ef3ca2e777146ada23",
@@ -41,14 +41,14 @@
41
41
  "collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
42
42
  "collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
43
43
  "config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
44
- "lib/layouts.js": "5b3c22cfdd537777418d3d22ce86ba522576c0101e7d51ea0db9b2667caf1f92",
45
- "lib/nav-link.js": "ab68e4f5d86665834d95f845bb9755ee3a7019089be3452571054f4a49befbbb",
46
- "lib/page.js": "1fb9a01c3080e8ecf65c19b1273b23d550a131fe30079a493a9f5b1c90b720ae",
44
+ "lib/layouts.js": "bbb61ac7ec2e0cecd3d59700e9f3bd1064cd53b2e9f639d28b45ffc2ad80ab27",
45
+ "lib/nav-link.js": "6a8f9368bb8aee1c21e90fb335a691859cb9a9bc250c2b9167a33ca9189fa2ff",
46
+ "lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
47
47
  "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
48
48
  "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
49
- "plugin.js": "9e49e3b1d4844cf5ec33abf350ca9cd38e8efa4b93be2c628842a923033350cf",
50
- "plugin.json": "20603ae07a6dade8f6fef14539e3b629c7c0c07dea8322834a4827f86dcf204e",
51
- "plugin.public.js": "c338fff588ab7bd02baf1bc6d5a729a0f0d2ecfa90c087cadfd792ba4d3fd9c2",
49
+ "plugin.js": "ccfd8735838bb6c8c5d45c3c3589d6adcf114a89b560f66e93a1077e9bea3bfb",
50
+ "plugin.json": "5654242b0fd5d4f6039a87dc429bd69a24355a59c3409be10998410fbe6d019b",
51
+ "plugin.public.js": "8efc1ff3a2ead68d36fbd545f8ce590f600b293896ba6f4947418b1efcdf2947",
52
52
  "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
53
53
  "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
54
54
  "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
@@ -61,9 +61,9 @@
61
61
  "roles/blog-editor.json": "bea0b673cf9e3da04c5882d51dd33bdcaf035b047c1cfe87624a9f16e6514ffc",
62
62
  "templates/index.html": "3c2cf8a4ffde8599d451f6d00b736aeba42fd073b726ada3b0ffe4d56e1ef0f4",
63
63
  "templates/post.html": "28e9f06071115ecc4fbba007b35782732bf12ef72528525893d424e3c4f0313f",
64
- "tests/layouts.test.js": "f6dc1ac17b1d4841accd673ab34cc127aa2ccdbca453ef1cee8dbb7a637669a6",
65
- "tests/nav-link.test.js": "d25a2ec7948df4b042c402e8c5f2cacb2be4743edba48d497a6b67a0ca1fdd5a",
66
- "tests/public.test.js": "ef04ba271d03cee86adac332c6834bb51102b20d892d5f84d30eb04507605de5"
64
+ "tests/layouts.test.js": "5325aad79a153f1d17f49b27223f50be182a2acce8ee8be7da658ae92ae36fe8",
65
+ "tests/nav-link.test.js": "d36e33ad7483abca27ae6e2098ad2f75bafa18d91f3beba70372ebe6b9b88994",
66
+ "tests/public.test.js": "0049a6be16c6a8acb44d53ea885909c80563a0afaa3e1e89d3c54d5429feed98"
67
67
  }
68
68
  },
69
69
  "mail-reader": {
@@ -159,6 +159,13 @@ export function canManageUser(actor, target) {
159
159
  * lets both roles in, plus anyone more privileged than either (typically
160
160
  * admins inherit access automatically via the level comparison).
161
161
  *
162
+ * Exact tokens: a role name prefixed with `=` (`=candidate`) skips the
163
+ * hierarchy and passes only for users who HOLD that role, primary or
164
+ * additional. That is the only way to keep two roles on the same level (or
165
+ * a lower-privilege role and a higher one) out of each other's pages and menu
166
+ * items. The root role (level 0) still passes, as it does for 'private'.
167
+ * Exact and hierarchical tokens mix freely in an array.
168
+ *
162
169
  * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
170
  * @param {string|string[]} visibility - Required visibility - single value or array
164
171
  * @returns {boolean} true if access is granted
@@ -194,12 +201,26 @@ export function checkVisibility(userRoleOrObj, visibility) {
194
201
  * @returns {boolean}
195
202
  */
196
203
  function checkSingleVisibility(userRole, visibility) {
204
+ const exact = parseExactVisibility(visibility);
205
+ if (exact) return userRole === exact || getRoleLevel(userRole) === 0;
197
206
  const userLevel = getRoleLevel(userRole);
198
207
  const requiredLevel = getRoleLevel(visibility);
199
208
  const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
200
209
  return userLevel <= threshold;
201
210
  }
202
211
 
212
+ /**
213
+ * The role an exact visibility token names, or null for any other token.
214
+ * `=candidate` → 'candidate'; `candidate`, `public`, `=` → null.
215
+ *
216
+ * @param {*} token
217
+ * @returns {string|null}
218
+ */
219
+ export function parseExactVisibility(token) {
220
+ if (typeof token !== 'string' || token.length < 2 || token[0] !== '=') return null;
221
+ return token.slice(1);
222
+ }
223
+
203
224
  /**
204
225
  * Fastify preHandler factory - gates a route by visibility level.
205
226
  * Works identically to the content-page visibility system; accepts the same
@@ -9,10 +9,13 @@
9
9
  */
10
10
  import crypto from 'node:crypto';
11
11
  import {config, getConfig} from '../../config.js';
12
- import {hooks} from '../../services/hooks.js';
13
- import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
14
- import {getRoleLevel} from '../../services/roles.js';
15
- import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
12
+ import {getRegisteredSidebarItems, hooks} from '../../services/hooks.js';
13
+ import {getMenu} from '../../services/menus.js';
14
+ import {resolveAdminHome} from '../../services/adminHome.js';
15
+ import {authenticate, checkVisibility} from '../../middleware/auth.js';
16
+ import {getAdminScope, getPermissionsForRoles, getRoleLevel} from '../../services/roles.js';
17
+ import {getEffectiveRegistry, GROUP_ORDER} from '../../services/permissionRegistry.js';
18
+ import {getEffectiveRoles} from '../../services/userRoles.js';
16
19
  import {
17
20
  clearResetToken,
18
21
  countUsers,
@@ -118,13 +121,31 @@ export async function authRoutes(fastify) {
118
121
 
119
122
  // GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
120
123
  fastify.get('/auth/permissions', {preHandler: [authenticate]}, async (request) => {
121
- const permissions = getPermissionsForRole(request.user.role);
122
- return {permissions};
124
+ // Every role the user holds, not just the primary one - the server
125
+ // grants on the union, so the sidebar must hide on the union too.
126
+ const roles = getEffectiveRoles(request.user);
127
+ const allResources = getEffectiveRegistry().map(r => r.key);
128
+ const permissions = getPermissionsForRoles(roles, allResources);
129
+ // adminScope: null = the whole admin; otherwise {home, allow} - the
130
+ // screens this user's roles confine them to (see roles.getAdminScope).
131
+ const adminScope = getAdminScope(roles);
132
+ // adminHome: the screen `#/` opens for this user - see services/adminHome.js.
133
+ // The menu is read only when a site home has to be checked against it.
134
+ const adminHome = await resolveAdminHome({
135
+ scope: adminScope,
136
+ permissions,
137
+ siteHome: getConfig('site')?.adminHome,
138
+ registered: () => getRegisteredSidebarItems().map(e => e.item),
139
+ menuItems: async () => (await getMenu('admin-sidebar').catch(() => null))?.items || [],
140
+ canSee: (visibility) => checkVisibility(request.user, visibility)
141
+ });
142
+ return {permissions, adminScope, adminHome};
123
143
  });
124
144
 
125
- // GET /api/auth/permissions-registry - returns the full permissions registry
145
+ // GET /api/auth/permissions-registry - returns the full permissions registry,
146
+ // including resources plugins registered, so the role editor can grant them.
126
147
  fastify.get('/auth/permissions-registry', {preHandler: [authenticate]}, async () => {
127
- return {groups: GROUP_ORDER, resources: REGISTRY};
148
+ return {groups: GROUP_ORDER, resources: getEffectiveRegistry()};
128
149
  });
129
150
 
130
151
  // GET /api/auth/me
@@ -202,6 +202,29 @@ export async function checkPublicAccess(schema, operation, request, reply) {
202
202
  }
203
203
  }
204
204
 
205
+ /**
206
+ * Role fields the system owns, not the editor: `plugin` says which plugin owns
207
+ * the role (hooks.unregisterRole and uninstall key off it) and `grantedBy`
208
+ * records one-time plugin grants. Clients send whole records, so without this
209
+ * any save from the role editor silently erased both - the role stopped
210
+ * belonging to its plugin and revoked grants came back on the next boot.
211
+ * Whatever the client sent for them is ignored in favour of what is stored.
212
+ *
213
+ * @param {string} id
214
+ * @param {object} data
215
+ * @returns {Promise<object>}
216
+ */
217
+ const ROLE_SYSTEM_FIELDS = ['plugin', 'grantedBy'];
218
+ async function keepRoleSystemFields(id, data) {
219
+ const existing = (await getEntry('roles', id))?.data || {};
220
+ const out = {...data};
221
+ for (const field of ROLE_SYSTEM_FIELDS) {
222
+ if (existing[field] !== undefined) out[field] = existing[field];
223
+ else delete out[field];
224
+ }
225
+ return out;
226
+ }
227
+
205
228
  export async function collectionsRoutes(fastify) {
206
229
  const canRead = {preHandler: [authenticate, requirePermission('collections', 'read')]};
207
230
  const canCreate = {preHandler: [authenticate, requirePermission('collections', 'create')]};
@@ -347,7 +370,13 @@ export async function collectionsRoutes(fastify) {
347
370
  fastify.post('/collections/:slug/entries', canCreate, async (request, reply) => {
348
371
  const user = request.user;
349
372
  try {
350
- const entry = await createEntry(request.params.slug, request.body?.data || {}, {
373
+ let data = request.body?.data || {};
374
+ // A role created in the admin belongs to no plugin; see keepRoleSystemFields.
375
+ if (request.params.slug === 'roles') {
376
+ data = {...data};
377
+ ROLE_SYSTEM_FIELDS.forEach(field => delete data[field]);
378
+ }
379
+ const entry = await createEntry(request.params.slug, data, {
351
380
  createdBy: user?.id || null,
352
381
  source: 'admin'
353
382
  });
@@ -395,7 +424,9 @@ export async function collectionsRoutes(fastify) {
395
424
 
396
425
  fastify.put('/collections/:slug/entries/:id', canUpdate, async (request, reply) => {
397
426
  try {
398
- const entry = await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
427
+ let data = request.body?.data || {};
428
+ if (request.params.slug === 'roles') data = await keepRoleSystemFields(request.params.id, data);
429
+ const entry = await updateEntry(request.params.slug, request.params.id, data);
399
430
  if (request.params.slug === 'roles') await invalidateRoles();
400
431
  hooks.emit('collection:entryUpdated', { slug: request.params.slug, entryId: entry.id, data: entry.data, userId: request.user?.id || null });
401
432
  return entry;
@@ -701,7 +732,13 @@ export async function collectionsRoutes(fastify) {
701
732
  if (denied !== undefined) return;
702
733
 
703
734
  try {
704
- const entry = await createEntry(request.params.slug, request.body?.data || {}, {
735
+ let data = request.body?.data || {};
736
+ // A role created in the admin belongs to no plugin; see keepRoleSystemFields.
737
+ if (request.params.slug === 'roles') {
738
+ data = {...data};
739
+ ROLE_SYSTEM_FIELDS.forEach(field => delete data[field]);
740
+ }
741
+ const entry = await createEntry(request.params.slug, data, {
705
742
  createdBy: request.apiToken ? `token:${request.apiToken.id}` : (request.user?.id || null),
706
743
  source: 'api'
707
744
  });
@@ -11,6 +11,7 @@ import fs from 'fs/promises';
11
11
  import path from 'path';
12
12
  import {fileURLToPath} from 'url';
13
13
  import * as cache from '../../services/cache/index.js';
14
+ import {normaliseAdminPath} from '../../services/roles.js';
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
@@ -41,6 +42,8 @@ export async function settingsRoutes(fastify) {
41
42
  'smtp', 'footer', 'analytics', 'social', 'locale',
42
43
  'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
43
44
  'adminBrand',
45
+ // The screen `#/` (Dashboard) opens - see services/adminHome.js.
46
+ 'adminHome',
44
47
  // Keys that legitimately live in site.json (written elsewhere - e.g.
45
48
  // `brand` by the menus migration) and are echoed back by GET /settings.
46
49
  // They must be accepted here or any client that round-trips the GET
@@ -51,6 +54,12 @@ export async function settingsRoutes(fastify) {
51
54
  if (unknownKeys.length > 0) {
52
55
  return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
53
56
  }
57
+ if ('adminHome' in data) {
58
+ // An admin route, or blank for the built-in dashboard.
59
+ const home = data.adminHome == null || data.adminHome === '' ? '/' : normaliseAdminPath(data.adminHome);
60
+ if (!home) return reply.status(400).send({error: 'adminHome must be an admin route such as /plugins/blog'});
61
+ data.adminHome = home;
62
+ }
54
63
  // Merge incoming data with existing config so partial updates (e.g. just adminBrand)
55
64
  // don't wipe unrelated keys. Nested objects get a shallow merge so sub-fields are
56
65
  // preserved even when only some sub-fields are sent.
@@ -1,23 +1,44 @@
1
1
  /**
2
2
  * Admin sidebar API
3
3
  *
4
- * GET /api/sidebar/registered-items - plugin-registered sidebar items
4
+ * GET /api/sidebar/menu - the admin-sidebar menu, filtered for the caller
5
+ * GET /api/sidebar/registered-items - plugin-registered sidebar items, filtered for the caller
6
+ *
7
+ * Both need only a signed-in user, NOT `menus.read`: every user who can reach
8
+ * the admin needs a sidebar, and a role without menu-editing rights (a
9
+ * `candidate`, say) used to get a 403 here and fall back to a hardcoded tree
10
+ * holding a Menus link they could not open. Items gated by `visibility` are
11
+ * dropped on the server, the same way the public navbar drops them; items
12
+ * gated by `permission`, and `hidden` items, are dropped by the renderer - it
13
+ * needs the hidden ones to know which Tools a menu has hidden.
5
14
  *
6
15
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
7
16
  */
8
- import {
9
- authenticate as defaultAuthenticate,
10
- requirePermission as defaultRequirePermission
11
- } from '../../middleware/auth.js';
17
+ import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
12
18
  import {getRegisteredSidebarItems} from '../../services/hooks.js';
19
+ import {filterItemsForUser, getMenu} from '../../services/menus.js';
20
+
21
+ export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
13
22
 
14
23
  export async function sidebarRoutes(fastify, opts = {}) {
15
- const authenticate = opts.authenticate || defaultAuthenticate;
16
- const requirePermission = opts.requirePermission || defaultRequirePermission;
24
+ const authenticate = opts.authenticate || defaultAuthenticate;
25
+ const signedIn = {preHandler: [authenticate]};
17
26
 
18
- const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
27
+ fastify.get('/sidebar/menu', signedIn, async (request) => {
28
+ const menu = await getMenu(ADMIN_SIDEBAR_SLUG);
29
+ if (!menu) return {items: []};
30
+ // Hidden items are kept: they are how a menu hides a Tool, and the
31
+ // renderer needs them to place Tools before it drops them. An item
32
+ // this user may not see (`visibility`) arrives as a label-less hidden
33
+ // stub for the same reason - see restrictedStub() in menus.js.
34
+ return {...menu, items: filterItemsForUser(menu.items || [], request.user, {keepHidden: true})};
35
+ });
19
36
 
20
- fastify.get('/sidebar/registered-items', canRead, async () => {
21
- return getRegisteredSidebarItems();
37
+ fastify.get('/sidebar/registered-items', signedIn, async (request) => {
38
+ return getRegisteredSidebarItems()
39
+ .filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
40
+ .map(entry => Array.isArray(entry.item?.items)
41
+ ? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
42
+ : entry);
22
43
  });
23
44
  }