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.
- package/CLAUDE.md +24 -3
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/app.js +5 -5
- package/admin/js/lib/activity-throbber.js +1 -0
- package/admin/js/lib/admin-scope.js +1 -0
- package/admin/js/lib/menu-rows.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/page-editor.html +1 -4
- package/admin/js/templates/role-editor.html +28 -0
- package/admin/js/templates/settings.html +8 -0
- package/admin/js/views/menu-editor.js +16 -16
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/role-editor.js +3 -1
- package/admin/js/views/settings.js +3 -3
- package/package.json +2 -2
- package/plugins/_template/plugin.js +5 -2
- package/plugins/blog/CLAUDE.md +20 -5
- package/plugins/blog/admin/views/kit.js +8 -10
- package/plugins/blog/lib/layouts.js +36 -0
- package/plugins/blog/lib/nav-link.js +22 -0
- package/plugins/blog/lib/page.js +13 -1
- package/plugins/blog/plugin.js +28 -9
- package/plugins/blog/plugin.json +6 -6
- package/plugins/blog/plugin.public.js +102 -80
- package/plugins/blog/tests/layouts.test.js +29 -0
- package/plugins/blog/tests/nav-link.test.js +10 -1
- package/plugins/blog/tests/public.test.js +21 -0
- package/plugins/free-tier.lock.json +13 -13
- package/server/middleware/auth.js +21 -0
- package/server/routes/api/auth.js +29 -8
- package/server/routes/api/collections.js +40 -3
- package/server/routes/api/settings.js +9 -0
- package/server/routes/api/sidebar.js +31 -10
- package/server/routes/public.js +13 -17
- package/server/server.js +10 -8
- package/server/services/adminHome.js +87 -0
- package/server/services/hooks.js +30 -6
- package/server/services/menus.js +36 -6
- package/server/services/permissionRegistry.js +10 -7
- package/server/services/pluginInstaller.js +23 -9
- package/server/services/plugins.js +333 -45
- package/server/services/roles.js +181 -5
- package/server/services/sidebar-migration.js +82 -8
- 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.
|
|
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,
|
|
70
|
+
* @param {object} opts - {base, title, intro, page, filter}
|
|
68
71
|
*/
|
|
69
|
-
async function renderArchive(request, reply, posts, {title, intro = '', page = 1,
|
|
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,
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
137
|
+
const siteTitle = config?.site?.title ?? 'Blog';
|
|
138
|
+
const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
|
|
134
139
|
|
|
135
|
-
|
|
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
|
-
|
|
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@
|
|
3
|
+
"source": "dcms-marketplace@d9bfe49",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
|
-
"version": "1.6.
|
|
6
|
+
"version": "1.6.3",
|
|
7
7
|
"files": {
|
|
8
|
-
"CLAUDE.md": "
|
|
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": "
|
|
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": "
|
|
45
|
-
"lib/nav-link.js": "
|
|
46
|
-
"lib/page.js": "
|
|
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": "
|
|
50
|
-
"plugin.json": "
|
|
51
|
-
"plugin.public.js": "
|
|
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": "
|
|
65
|
-
"tests/nav-link.test.js": "
|
|
66
|
-
"tests/public.test.js": "
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
-
|
|
122
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
16
|
-
const
|
|
24
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
25
|
+
const signedIn = {preHandler: [authenticate]};
|
|
17
26
|
|
|
18
|
-
|
|
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',
|
|
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
|
}
|