domma-cms 0.70.0 → 0.71.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/js/app.js +3 -3
- package/admin/js/lib/admin-scope.js +1 -0
- 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/views/menu-editor.js +5 -5
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/role-editor.js +3 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +2 -0
- 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 +15 -7
- package/server/routes/api/collections.js +40 -3
- package/server/routes/api/sidebar.js +26 -10
- package/server/routes/public.js +13 -17
- package/server/services/menus.js +1 -1
- package/server/services/permissionRegistry.js +10 -7
- package/server/services/pluginInstaller.js +23 -9
- package/server/services/plugins.js +245 -40
- package/server/services/roles.js +170 -5
|
@@ -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
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import {config, getConfig} from '../../config.js';
|
|
12
12
|
import {hooks} from '../../services/hooks.js';
|
|
13
|
-
import {authenticate
|
|
14
|
-
import {getRoleLevel} from '../../services/roles.js';
|
|
15
|
-
import {
|
|
13
|
+
import {authenticate} from '../../middleware/auth.js';
|
|
14
|
+
import {getAdminScope, getPermissionsForRoles, getRoleLevel} from '../../services/roles.js';
|
|
15
|
+
import {getEffectiveRegistry, GROUP_ORDER} from '../../services/permissionRegistry.js';
|
|
16
|
+
import {getEffectiveRoles} from '../../services/userRoles.js';
|
|
16
17
|
import {
|
|
17
18
|
clearResetToken,
|
|
18
19
|
countUsers,
|
|
@@ -118,13 +119,20 @@ export async function authRoutes(fastify) {
|
|
|
118
119
|
|
|
119
120
|
// GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
|
|
120
121
|
fastify.get('/auth/permissions', {preHandler: [authenticate]}, async (request) => {
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
// Every role the user holds, not just the primary one - the server
|
|
123
|
+
// grants on the union, so the sidebar must hide on the union too.
|
|
124
|
+
const roles = getEffectiveRoles(request.user);
|
|
125
|
+
const allResources = getEffectiveRegistry().map(r => r.key);
|
|
126
|
+
const permissions = getPermissionsForRoles(roles, allResources);
|
|
127
|
+
// adminScope: null = the whole admin; otherwise {home, allow} - the
|
|
128
|
+
// screens this user's roles confine them to (see roles.getAdminScope).
|
|
129
|
+
return {permissions, adminScope: getAdminScope(roles)};
|
|
123
130
|
});
|
|
124
131
|
|
|
125
|
-
// GET /api/auth/permissions-registry - returns the full permissions registry
|
|
132
|
+
// GET /api/auth/permissions-registry - returns the full permissions registry,
|
|
133
|
+
// including resources plugins registered, so the role editor can grant them.
|
|
126
134
|
fastify.get('/auth/permissions-registry', {preHandler: [authenticate]}, async () => {
|
|
127
|
-
return {groups: GROUP_ORDER, resources:
|
|
135
|
+
return {groups: GROUP_ORDER, resources: getEffectiveRegistry()};
|
|
128
136
|
});
|
|
129
137
|
|
|
130
138
|
// 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
|
});
|
|
@@ -1,23 +1,39 @@
|
|
|
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` are dropped by the renderer, which holds the list.
|
|
5
13
|
*
|
|
6
14
|
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
7
15
|
*/
|
|
8
|
-
import {
|
|
9
|
-
authenticate as defaultAuthenticate,
|
|
10
|
-
requirePermission as defaultRequirePermission
|
|
11
|
-
} from '../../middleware/auth.js';
|
|
16
|
+
import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
|
|
12
17
|
import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
18
|
+
import {filterItemsForUser, getMenu} from '../../services/menus.js';
|
|
19
|
+
|
|
20
|
+
export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
|
|
13
21
|
|
|
14
22
|
export async function sidebarRoutes(fastify, opts = {}) {
|
|
15
|
-
const authenticate
|
|
16
|
-
const
|
|
23
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
24
|
+
const signedIn = {preHandler: [authenticate]};
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
fastify.get('/sidebar/menu', signedIn, async (request) => {
|
|
27
|
+
const menu = await getMenu(ADMIN_SIDEBAR_SLUG);
|
|
28
|
+
if (!menu) return {items: []};
|
|
29
|
+
return {...menu, items: filterItemsForUser(menu.items || [], request.user)};
|
|
30
|
+
});
|
|
19
31
|
|
|
20
|
-
fastify.get('/sidebar/registered-items',
|
|
21
|
-
return getRegisteredSidebarItems()
|
|
32
|
+
fastify.get('/sidebar/registered-items', signedIn, async (request) => {
|
|
33
|
+
return getRegisteredSidebarItems()
|
|
34
|
+
.filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
|
|
35
|
+
.map(entry => Array.isArray(entry.item?.items)
|
|
36
|
+
? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
|
|
37
|
+
: entry);
|
|
22
38
|
});
|
|
23
39
|
}
|
package/server/routes/public.js
CHANGED
|
@@ -213,8 +213,12 @@ export async function publicRoutes(fastify) {
|
|
|
213
213
|
// response must not reveal that an unpublished page exists there.
|
|
214
214
|
const isDraft = page.status !== 'published';
|
|
215
215
|
|
|
216
|
-
// Enforce page visibility
|
|
217
|
-
//
|
|
216
|
+
// Enforce page visibility. The viewer is resolved on EVERY request,
|
|
217
|
+
// not only for gated pages: the navbar, footers, overlays and [menu]
|
|
218
|
+
// shortcodes on a public page filter their items by role too, and a
|
|
219
|
+
// signed-in visitor rendered as anonymous never sees the items meant
|
|
220
|
+
// for them. Resolving is a cookie read plus a JWT verify, and returns
|
|
221
|
+
// null at once for anonymous traffic, which still shares one entry.
|
|
218
222
|
//
|
|
219
223
|
// `visibility` may be a string ('public' | 'private' | role name) or
|
|
220
224
|
// an array of role names - see checkVisibility() for full semantics.
|
|
@@ -222,25 +226,17 @@ export async function publicRoutes(fastify) {
|
|
|
222
226
|
// 'public') skip viewer resolution entirely so anonymous traffic hits
|
|
223
227
|
// the shared cache without auth cost - unless the page is a draft,
|
|
224
228
|
// which needs the viewer regardless of its visibility.
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// is correctness: any user whose access depends on an additional role
|
|
230
|
-
// is granted, but their served HTML is the variant cached for their
|
|
231
|
-
// primary role's tier - fine for the public-site use case.
|
|
232
|
-
let userRole = null;
|
|
233
|
-
let userObj = null;
|
|
229
|
+
// The cache key carries every role the viewer holds (sorted), since
|
|
230
|
+
// exact-role visibility (`=candidate`) makes an additional role change
|
|
231
|
+
// what renders. Real sites hold a handful of role combinations, so
|
|
232
|
+
// the entry count stays bounded by what users actually have.
|
|
234
233
|
const vis = page.visibility;
|
|
235
234
|
const isPublic = !vis
|
|
236
235
|
|| vis === 'public'
|
|
237
236
|
|| (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
|
|
238
237
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
userObj = await resolveViewer(request);
|
|
242
|
-
userRole = userObj ? userObj.role : null;
|
|
243
|
-
}
|
|
238
|
+
const userObj = await resolveViewer(request);
|
|
239
|
+
const userRole = userObj ? [...getEffectiveRoles(userObj)].sort().join('+') : null;
|
|
244
240
|
|
|
245
241
|
if (isDraft && !canPreviewDrafts(userObj)) {
|
|
246
242
|
reply.status(404);
|
|
@@ -256,7 +252,7 @@ export async function publicRoutes(fastify) {
|
|
|
256
252
|
|
|
257
253
|
// Re-parse with user context so the body's [menu] shortcode sees the
|
|
258
254
|
// visitor's role for visibility filtering. The first getPage() above
|
|
259
|
-
// was anonymous
|
|
255
|
+
// was anonymous - it only needed the page's metadata.
|
|
260
256
|
const render = async () => {
|
|
261
257
|
const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
|
|
262
258
|
return renderPage(pageForRole, {
|
package/server/services/menus.js
CHANGED
|
@@ -1082,7 +1082,7 @@ export async function resolveOverlaysForPage(ctx = {}, user = null) {
|
|
|
1082
1082
|
.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
|
|
1083
1083
|
}
|
|
1084
1084
|
|
|
1085
|
-
function filterItemsForUser(items, user) {
|
|
1085
|
+
export function filterItemsForUser(items, user) {
|
|
1086
1086
|
const out = [];
|
|
1087
1087
|
for (const item of items) {
|
|
1088
1088
|
if (item.hidden) continue;
|
|
@@ -322,21 +322,24 @@ const _pluginContributed = [];
|
|
|
322
322
|
* @param {string} plugin - Plugin name that owns this resource
|
|
323
323
|
*/
|
|
324
324
|
export function registerPluginResource(resource, plugin) {
|
|
325
|
+
if (!resource || typeof resource.key !== 'string' || !resource.key) return;
|
|
325
326
|
const effective = getEffectiveRegistry();
|
|
326
327
|
if (effective.some(r => r.key === resource.key)) return;
|
|
327
328
|
|
|
328
329
|
_pluginContributed.push({
|
|
329
330
|
key: resource.key,
|
|
330
|
-
label: resource.label,
|
|
331
|
-
description: resource.description || `${resource.label} (${plugin} plugin)`,
|
|
331
|
+
label: resource.label || resource.key,
|
|
332
|
+
description: resource.description || `${resource.label || resource.key} (${plugin} plugin)`,
|
|
332
333
|
icon: resource.icon || 'box',
|
|
333
334
|
group: resource.group || 'Plugins',
|
|
334
335
|
plugin,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
336
|
+
// Actions may be objects or bare keys (`["read", "create"]`) - the
|
|
337
|
+
// short form is what plugin role files have always shipped.
|
|
338
|
+
actions: Array.isArray(resource.actions) && resource.actions.length
|
|
339
|
+
? resource.actions.map(a => typeof a === 'string'
|
|
340
|
+
? {key: a, label: a, description: a}
|
|
341
|
+
: {key: a.key, label: a.label || a.key, description: a.description || a.label || a.key})
|
|
342
|
+
: ACTIONS.map(key => ({key, label: key, description: key}))
|
|
340
343
|
});
|
|
341
344
|
}
|
|
342
345
|
|
|
@@ -16,7 +16,7 @@ import path from 'path';
|
|
|
16
16
|
import zlib from 'zlib';
|
|
17
17
|
import {fileURLToPath} from 'url';
|
|
18
18
|
import {getConfig} from '../config.js';
|
|
19
|
-
import {discoverPlugins, validatePluginDir} from './plugins.js';
|
|
19
|
+
import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
|
|
20
20
|
import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
|
|
21
21
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
@@ -476,10 +476,11 @@ async function upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngra
|
|
|
476
476
|
* Uninstall a plugin by slug.
|
|
477
477
|
*
|
|
478
478
|
* Steps:
|
|
479
|
-
* 1. Run onDisable lifecycle hook (warn-only on failure)
|
|
480
|
-
* 2. Remove
|
|
481
|
-
* 3. Remove
|
|
482
|
-
* 4.
|
|
479
|
+
* 1. Run onDisable lifecycle hook (warn-only on failure), with `uninstall: true`
|
|
480
|
+
* 2. Remove the roles the plugin registered, moving their users to `user`
|
|
481
|
+
* 3. Remove plugins/<slug>/ directory
|
|
482
|
+
* 4. Remove entry from config/plugins.json
|
|
483
|
+
* 5. Append uninstall record to audit log
|
|
483
484
|
*
|
|
484
485
|
* @param {string} slug
|
|
485
486
|
* @returns {Promise<void>}
|
|
@@ -496,23 +497,36 @@ export async function uninstallPlugin(slug) {
|
|
|
496
497
|
const pluginJsPath = path.join(destDir, 'plugin.js');
|
|
497
498
|
const mod = await import(pluginJsPath);
|
|
498
499
|
if (typeof mod.onDisable === 'function') {
|
|
499
|
-
|
|
500
|
+
// Same shape as an ordinary disable (it used to be null, which
|
|
501
|
+
// threw in any onDisable that destructured its argument), plus
|
|
502
|
+
// `uninstall` so a plugin can tell removal from a pause.
|
|
503
|
+
await mod.onDisable({fastify: null, services: {}, hooks: pluginRoleHooks(slug), uninstall: true});
|
|
500
504
|
}
|
|
501
505
|
} catch (err) {
|
|
502
506
|
console.warn(`[pluginInstaller] onDisable hook for "${slug}" failed: ${err.message}`);
|
|
503
507
|
}
|
|
504
508
|
|
|
505
|
-
// Step 2 -
|
|
509
|
+
// Step 2 - Roles. A disable keeps them so a pause demotes nobody; an
|
|
510
|
+
// uninstall removes whatever the plugin still owns, whether it called
|
|
511
|
+
// unregisterRole itself or not.
|
|
512
|
+
try {
|
|
513
|
+
const removed = await unregisterAllRolesForPlugin(slug);
|
|
514
|
+
if (removed.length) console.log(`[pluginInstaller] Removed roles for "${slug}": ${removed.join(', ')}`);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.warn(`[pluginInstaller] Role removal for "${slug}" failed: ${err.message}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Step 3 - Remove plugin directory
|
|
506
520
|
await fs.rm(destDir, { recursive: true, force: true });
|
|
507
521
|
|
|
508
|
-
// Step
|
|
522
|
+
// Step 4 - Remove from config/plugins.json
|
|
509
523
|
const cfg = await readPluginsConfig();
|
|
510
524
|
if (slug in cfg) {
|
|
511
525
|
delete cfg[slug];
|
|
512
526
|
await writePluginsConfig(cfg);
|
|
513
527
|
}
|
|
514
528
|
|
|
515
|
-
// Step
|
|
529
|
+
// Step 5 - Audit log
|
|
516
530
|
await writeAuditLog({
|
|
517
531
|
action: 'uninstall',
|
|
518
532
|
slug,
|