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
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/server.js
CHANGED
|
@@ -280,8 +280,10 @@ try {
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
try {
|
|
283
|
-
const {runMigration: runSidebarMigration, ensureSidebarItem, ensureDocumentationSubmenus} = await import('./services/sidebar-migration.js');
|
|
283
|
+
const {runMigration: runSidebarMigration, ensureSidebarItem, ensureDocumentationSubmenus, ensureFolderKeys} = await import('./services/sidebar-migration.js');
|
|
284
284
|
await runSidebarMigration();
|
|
285
|
+
// Stable folder keys - what a Tool's default `folder` names.
|
|
286
|
+
await ensureFolderKeys();
|
|
285
287
|
// Surface admin pages added after first boot on existing installs.
|
|
286
288
|
await ensureSidebarItem({
|
|
287
289
|
groupText: 'System',
|
|
@@ -510,19 +512,19 @@ await app.register(contactsRoutes, {prefix: '/api/contacts'});
|
|
|
510
512
|
// Registered through the same channel plugins use, deliberately. The sidebar
|
|
511
513
|
// menu (config/menus/admin-sidebar.json) is seeded once and file-existence
|
|
512
514
|
// guarded, so adding an entry there would reach new sites only - every existing
|
|
513
|
-
// site would simply never see Notes or Todo. Registered items are
|
|
514
|
-
// render time on every site
|
|
515
|
-
//
|
|
515
|
+
// site would simply never see Notes or Todo. Registered items are placed at
|
|
516
|
+
// render time on every site (placeTools() in admin/js/lib/sidebar-grouping.js):
|
|
517
|
+
// in the Tools folder by default, or wherever an admin has moved them.
|
|
516
518
|
//
|
|
517
519
|
// `permission` is what hides each one from a role that cannot use it; the
|
|
518
520
|
// renderer filters on it.
|
|
519
521
|
const {registerSidebarItem} = await import('./services/hooks.js');
|
|
520
522
|
// `countUrl` is the badge: the sidebar fetches it and shows {count, label, tone}
|
|
521
523
|
// (see fetchCounts in admin/js/lib/sidebar-renderer.js).
|
|
522
|
-
registerSidebarItem({item: {text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'}});
|
|
523
|
-
registerSidebarItem({item: {text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'}});
|
|
524
|
-
registerSidebarItem({item: {text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'}});
|
|
525
|
-
registerSidebarItem({item: {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}});
|
|
524
|
+
registerSidebarItem({item: {source: 'Built in', text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'}});
|
|
525
|
+
registerSidebarItem({item: {source: 'Built in', text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'}});
|
|
526
|
+
registerSidebarItem({item: {source: 'Built in', text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'}});
|
|
527
|
+
registerSidebarItem({item: {source: 'Built in', text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}});
|
|
526
528
|
|
|
527
529
|
await registerPlugins(app);
|
|
528
530
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin home - the screen `#/` (Dashboard) opens.
|
|
3
|
+
*
|
|
4
|
+
* Every admin screen is a Tool, and "Dashboard" is simply whichever Tool home
|
|
5
|
+
* points at. Three sources, first answer wins:
|
|
6
|
+
*
|
|
7
|
+
* 1. A confined role's own home (roles.getAdminScope) - it is also the only
|
|
8
|
+
* place a confined user may land.
|
|
9
|
+
* 2. The site's `adminHome` (config/site.json, System > Site Settings).
|
|
10
|
+
* 3. `/` - the built-in dashboard, or the plugin view that replaces it
|
|
11
|
+
* (`views.dashboard`).
|
|
12
|
+
*
|
|
13
|
+
* The site's choice is skipped when the sidebar does not know that screen (a
|
|
14
|
+
* Tool whose plugin has been disabled) or the user lacks the permission guarding
|
|
15
|
+
* it (its sidebar entry's `permission`), so nobody lands on a dead end. The server stays the authority for data either way;
|
|
16
|
+
* this only decides where the admin opens.
|
|
17
|
+
*/
|
|
18
|
+
import {normaliseAdminPath} from './roles.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True if `permissions` grants `resource` - the same test the sidebar uses
|
|
22
|
+
* (sidebar-renderer.js canAny), so home never opens what the sidebar hides.
|
|
23
|
+
*/
|
|
24
|
+
function holds(permissions, resource) {
|
|
25
|
+
if (!resource) return true;
|
|
26
|
+
const p = permissions || [];
|
|
27
|
+
return p.includes(resource) || ['read', 'create', 'update', 'delete'].some(a => p.includes(`${resource}.${a}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trimUrl = (url) => (typeof url === 'string' ? url.replace(/\/+$/, '') || '#/' : '');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The path, top down, to the first menu entry whose url is `url` - a Tool
|
|
34
|
+
* placeholder (`ref`) included, since it is where that Tool renders - or null.
|
|
35
|
+
*/
|
|
36
|
+
function pathTo(url, nodes, trail = []) {
|
|
37
|
+
for (const n of nodes || []) {
|
|
38
|
+
if (!n) continue;
|
|
39
|
+
if (trimUrl(n.url) === url) return [...trail, n];
|
|
40
|
+
const hit = pathTo(url, n.items, [...trail, n]);
|
|
41
|
+
if (hit) return hit;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True if the menu hides `text` the older way: a url-less, childless hidden item of that name. */
|
|
47
|
+
function hiddenByName(text, nodes) {
|
|
48
|
+
const t = String(text || '').toLowerCase();
|
|
49
|
+
for (const n of nodes || []) {
|
|
50
|
+
if (!n) continue;
|
|
51
|
+
if (n.hidden && !n.url && !(n.items || []).length && String(n.text || '').toLowerCase() === t) return true;
|
|
52
|
+
if (hiddenByName(text, n.items)) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} o
|
|
59
|
+
* @param {{home: string}|null} o.scope the user's admin scope (confinement)
|
|
60
|
+
* @param {string[]} o.permissions what the user holds
|
|
61
|
+
* @param {unknown} o.siteHome site.json `adminHome`
|
|
62
|
+
* @param {object[]|Function} [o.registered] registered sidebar items (their `.item`s), or a function returning them
|
|
63
|
+
* @param {object[]|Function} [o.menuItems] the admin-sidebar menu's items, or a function returning them
|
|
64
|
+
* @param {(visibility: unknown) => boolean} [o.canSee] the sidebar's visibility test for this user
|
|
65
|
+
* @returns {Promise<string>|string} an admin route path
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveAdminHome({scope, permissions, siteHome, registered = [], menuItems = [], canSee = () => true}) {
|
|
68
|
+
if (scope?.home) return scope.home;
|
|
69
|
+
const home = normaliseAdminPath(siteHome);
|
|
70
|
+
if (!home || home === '/') return '/';
|
|
71
|
+
const url = `#${home}`;
|
|
72
|
+
const list = async (v) => (typeof v === 'function' ? await v() : v) || [];
|
|
73
|
+
// Only a screen the sidebar shows this user: an enabled Tool, or a page in
|
|
74
|
+
// the menu. Where the menu places it decides whether it is shown - a hidden
|
|
75
|
+
// entry, or a hidden or gated folder on the way to it, rules it out even
|
|
76
|
+
// for a registered Tool, exactly as the sidebar leaves it out.
|
|
77
|
+
const tool = (await list(registered)).find(i => i && !i.hidden && trimUrl(i.url) === url);
|
|
78
|
+
const items = await list(menuItems);
|
|
79
|
+
const path = pathTo(url, items);
|
|
80
|
+
if (!tool && (!path || path.at(-1).ref)) return '/'; // nothing enables it
|
|
81
|
+
if (tool && !path && hiddenByName(tool.text, items)) return '/';
|
|
82
|
+
const gates = [...(path || []), ...(tool ? [tool] : [])];
|
|
83
|
+
const open = gates.every(n => !n.hidden
|
|
84
|
+
&& holds(permissions, n.permission)
|
|
85
|
+
&& (n.visibility == null || canSee(n.visibility)));
|
|
86
|
+
return open ? home : '/';
|
|
87
|
+
}
|
package/server/services/hooks.js
CHANGED
|
@@ -71,27 +71,51 @@ export async function registerMenuLocation(slot, def) {
|
|
|
71
71
|
const _registeredSidebarItems = [];
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Register a sidebar
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* Register a Tool's sidebar entry. Entries are placed into the rendered admin
|
|
75
|
+
* sidebar at render time - they are NOT persisted into menu JSON - so plugins
|
|
76
|
+
* re-register on each server boot.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
78
|
+
* `folder` is the Tool's DEFAULT place: the `key` of an admin-sidebar folder
|
|
79
|
+
* (`'tools'`, `'content'`, `'data'`, `'system'`, ...). It defaults to `'tools'`,
|
|
80
|
+
* and an admin who moves the Tool in the menu overrides it - the renderer
|
|
81
|
+
* places by `url` first (see placeTools in admin/js/lib/sidebar-grouping.js).
|
|
82
|
+
* `parent` is the older form: any node matched by its text.
|
|
83
|
+
*
|
|
84
|
+
* @param {{folder?: string, parent?: string, item: {text: string, url: string, icon?: string, permission?: string, folder?: string, items?: object[]}}} opts
|
|
79
85
|
*/
|
|
80
86
|
export function registerSidebarItem(opts) {
|
|
81
87
|
if (!opts || !opts.item || typeof opts.item.text !== 'string') {
|
|
82
88
|
throw new Error('registerSidebarItem: opts.item.text is required');
|
|
83
89
|
}
|
|
90
|
+
const {folder: itemFolder, ...item} = opts.item;
|
|
84
91
|
_registeredSidebarItems.push({
|
|
85
92
|
parent: opts.parent || null,
|
|
86
|
-
|
|
93
|
+
folder: opts.folder || itemFolder || DEFAULT_TOOL_FOLDER,
|
|
94
|
+
item
|
|
87
95
|
});
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
/** Where a Tool's sidebar entry goes when it names no folder. */
|
|
99
|
+
export const DEFAULT_TOOL_FOLDER = 'tools';
|
|
100
|
+
|
|
101
|
+
/** Sidebar URLs taken off the sidebar - a built-in Tool a plugin has taken over. */
|
|
102
|
+
const _hiddenSidebarUrls = new Set();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Leave the item with this URL out of the sidebar. For a core Tool whose entry
|
|
106
|
+
* is registered before the plugins load, so it cannot simply not be registered.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} url - e.g. '#/contacts'
|
|
109
|
+
*/
|
|
110
|
+
export function hideSidebarUrl(url) {
|
|
111
|
+
_hiddenSidebarUrls.add(url);
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
/**
|
|
91
115
|
* @returns {Array<{parent: string|null, item: object}>}
|
|
92
116
|
*/
|
|
93
117
|
export function getRegisteredSidebarItems() {
|
|
94
|
-
return _registeredSidebarItems.
|
|
118
|
+
return _registeredSidebarItems.filter(entry => !_hiddenSidebarUrls.has(entry?.item?.url));
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
// ---------------------------------------------------------------------------
|
package/server/services/menus.js
CHANGED
|
@@ -714,15 +714,22 @@ export function validateMenu(menu) {
|
|
|
714
714
|
return;
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
-
|
|
717
|
+
// A Tool placeholder (`ref`, admin-sidebar) takes its label from the
|
|
718
|
+
// Tool's registration unless the admin gave it one of its own.
|
|
719
|
+
if (item.ref === true && item.url && !item.text) {
|
|
720
|
+
// fine - the url is checked below like any leaf
|
|
721
|
+
} else if (!item.text || typeof item.text !== 'string') {
|
|
718
722
|
errors.push(`Item ${path}: text is required`);
|
|
719
723
|
}
|
|
720
724
|
// url is only required for LEAF items (no children). Folder items
|
|
721
725
|
// - those with their own `items` array - are pure groupings and
|
|
722
726
|
// don't need a navigation target. The admin sidebar uses this
|
|
723
727
|
// shape extensively (Content / Data / System headings etc.).
|
|
728
|
+
// A keyed item is an admin-sidebar folder (see sidebar-migration.js)
|
|
729
|
+
// even while empty - the Tools folder is, until a Tool is enabled.
|
|
724
730
|
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
|
725
|
-
|
|
731
|
+
const isKeyedFolder = typeof item.key === 'string' && item.key !== '' && !item.url;
|
|
732
|
+
if (!hasChildren && !isKeyedFolder) {
|
|
726
733
|
if (!item.url || typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url)) {
|
|
727
734
|
errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
|
|
728
735
|
}
|
|
@@ -1082,15 +1089,38 @@ export async function resolveOverlaysForPage(ctx = {}, user = null) {
|
|
|
1082
1089
|
.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
|
|
1083
1090
|
}
|
|
1084
1091
|
|
|
1085
|
-
|
|
1092
|
+
/**
|
|
1093
|
+
* The admin sidebar's stand-in for an item this user may not see: only its
|
|
1094
|
+
* url (or folder key) and `hidden`, so Tool placement still knows the admin put
|
|
1095
|
+
* a Tool there - it must not fall back into Tools for exactly the users it was
|
|
1096
|
+
* restricted from - while the label never reaches them.
|
|
1097
|
+
*/
|
|
1098
|
+
function restrictedStub(item) {
|
|
1099
|
+
const children = Array.isArray(item.items) ? item.items.map(restrictedStub).filter(Boolean) : [];
|
|
1100
|
+
if (!item.url && !item.key && !children.length) return null;
|
|
1101
|
+
return {
|
|
1102
|
+
...(item.url && {url: item.url}),
|
|
1103
|
+
...(item.key && {key: item.key}),
|
|
1104
|
+
hidden: true,
|
|
1105
|
+
// Marks a role gate, as opposed to an admin's hide, for ensureLockedTools.
|
|
1106
|
+
restricted: true,
|
|
1107
|
+
...(children.length && {items: children})
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
export function filterItemsForUser(items, user, {keepHidden = false} = {}) {
|
|
1086
1112
|
const out = [];
|
|
1087
1113
|
for (const item of items) {
|
|
1088
|
-
if (item.hidden) continue;
|
|
1114
|
+
if (item.hidden && !keepHidden) continue;
|
|
1089
1115
|
if (item.visibility != null) {
|
|
1090
|
-
if (!checkVisibility(user, item.visibility))
|
|
1116
|
+
if (!checkVisibility(user, item.visibility)) {
|
|
1117
|
+
const stub = keepHidden ? restrictedStub(item) : null;
|
|
1118
|
+
if (stub) out.push(stub);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1091
1121
|
}
|
|
1092
1122
|
const children = Array.isArray(item.items)
|
|
1093
|
-
? filterItemsForUser(item.items, user)
|
|
1123
|
+
? filterItemsForUser(item.items, user, {keepHidden})
|
|
1094
1124
|
: [];
|
|
1095
1125
|
out.push({...item, items: children});
|
|
1096
1126
|
}
|
|
@@ -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,
|