domma-cms 1.10.29 → 1.10.31
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/CHANGELOG.md +8 -0
- package/CLAUDE.md +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/config/menus/admin-sidebar.json +6 -11
- package/docs/plugin-development.md +1 -0
- package/package.json +1 -1
- package/server/server.js +2 -3
- package/server/services/outbound.js +59 -0
- package/server/services/plugins.js +4 -1
- package/server/services/sidebar-migration.js +32 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.31] - 2026-10-26
|
|
4
|
+
|
|
5
|
+
- **Plugins can fetch user-chosen addresses safely.** `hooks.safeFetch(url, opts)` hands plugins the CMS's own outbound guard: public addresses only, the connection pinned to the checked address, redirects re-checked, ports 80 and 443, and the site's `outboundAllowPrivate` rule (ignored on hosted sites). Through the hook a plugin cannot pass in its own policy or resolver, and may raise the size, time and redirect limits only to 25 MB, 60 s and 5. It guards against plugin mistakes; on hosted sites plugin signing is what keeps out hostile code.
|
|
6
|
+
|
|
7
|
+
## [1.10.30] - 2026-10-26
|
|
8
|
+
|
|
9
|
+
- Admin navigation keeps My Profile in the top account menu, removing its redundant System entry. Search defaults to Content, and existing System entries move there once while retaining saved settings and renamed folders.
|
|
10
|
+
|
|
3
11
|
## [1.10.29] - 2026-10-26
|
|
4
12
|
|
|
5
13
|
- **Pro Tools open again on sites behind the Manager.** The admin's versioned-file check ran before the Manager handed a request to its site, so a site's request for a Tool's screen (Analytics Pro's dashboard, for one) was answered from the Manager's own copy of that plugin; where the two builds differ the fingerprint never matched and the screen 404'd. The check now runs after the plugins' hooks, so the site answers its own files.
|
package/CLAUDE.md
CHANGED
|
@@ -132,7 +132,7 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
|
|
|
132
132
|
`$graphLookup`, server-state stages, a `$lookup` not into a site `cms_<slug>` collection) and `__proto__`/`constructor`/
|
|
133
133
|
`prototype`. A new path that hands user input to `find`/`aggregate` must go through one of the two
|
|
134
134
|
- **Outbound requests to user-chosen URLs go through `services/outbound.js` `safeFetch()`** (Actions' webhook step,
|
|
135
|
-
form webhooks; plugins
|
|
135
|
+
form webhooks; plugins use `hooks.safeFetch` - `pluginSafeFetch()`, which drops any policy/resolver they pass) - never bare `fetch`. Public addresses only after DNS, the
|
|
136
136
|
connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
|
|
137
137
|
`server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
|
|
138
138
|
ignored when `codePolicy.signedOnly()`. `wpImport/fetch.js` shares its address rules and pinned request
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isPathAllowed as F}from"./admin-scope.js";export const TOOLS_FOLDER_TEXT="Tools",MANAGE_PLUGINS_URL="#/plugins",MANAGE_PLUGINS_ITEM={text:"Marketplace",url:MANAGE_PLUGINS_URL,icon:"store",permission:"plugins"};export function stripItemByUrl(e,t){const n=[];for(const r of e||[]){if(!r||r.url===t)continue;const s={...r};Array.isArray(r.items)&&r.items.length&&(s.items=stripItemByUrl(r.items,t)),n.push(s)}return n}export function managePluginsItem(){return{...MANAGE_PLUGINS_ITEM}}export const TOOLS_FOLDER_KEY="tools";const E=["countUrl","core","permission"],c=e=>String(e??"").toLowerCase();export function slugify(e){return c(e).trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}export function groupKeyFor(e,t){return`${c(e)}.${slugify(t)}`}export const GROUP_ICON="folder";const P=e=>e&&(e.type==="separator"||e.type==="spacer"||e.type==="theme-switcher"),A=e=>e&&!P(e)&&!e.url;function w(e){return(e||[]).filter(Boolean).map(t=>Array.isArray(t.items)?{...t,items:w(t.items)}:{...t})}function
|
|
1
|
+
import{isPathAllowed as F}from"./admin-scope.js";export const TOOLS_FOLDER_TEXT="Tools",MANAGE_PLUGINS_URL="#/plugins",MANAGE_PLUGINS_ITEM={text:"Marketplace",url:MANAGE_PLUGINS_URL,icon:"store",permission:"plugins"};export function stripItemByUrl(e,t){const n=[];for(const r of e||[]){if(!r||r.url===t)continue;const s={...r};Array.isArray(r.items)&&r.items.length&&(s.items=stripItemByUrl(r.items,t)),n.push(s)}return n}export function managePluginsItem(){return{...MANAGE_PLUGINS_ITEM}}export const TOOLS_FOLDER_KEY="tools";const E=["countUrl","core","permission"],c=e=>String(e??"").toLowerCase();export function slugify(e){return c(e).trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}export function groupKeyFor(e,t){return`${c(e)}.${slugify(t)}`}export const GROUP_ICON="folder";const P=e=>e&&(e.type==="separator"||e.type==="spacer"||e.type==="theme-switcher"),A=e=>e&&!P(e)&&!e.url;function w(e){return(e||[]).filter(Boolean).map(t=>Array.isArray(t.items)?{...t,items:w(t.items)}:{...t})}function N(e,t,n=[]){for(const r of e||[]){if(!r)continue;if(t(r))return[...n,r];const s=N(r.items,t,[...n,r]);if(s)return s}return null}function I(e,t,n=[],r=[]){for(const s of e||[])s&&(t(s)&&r.push([...n,s]),I(s.items,t,[...n,s],r));return r}const U=e=>!e.some(t=>t.hidden||t.restricted);function $(e,t){for(const n of e||[]){if(t(n))return n;const r=$(n?.items,t);if(r)return r}return null}export function findFolder(e,t){if(!t)return null;const n=c(t);return $(e,r=>A(r)&&c(r.key)===n)||$(e,r=>A(r)&&!r.key&&c(r.text)===n)}function b(e,t){const n={...t,...e};for(const r of E)t[r]!==void 0&&(n[r]=t[r]);return!(Array.isArray(e.items)&&e.items.length)&&Array.isArray(t.items)&&(n.items=t.items),e.text||(n.text=t.text),!e.icon&&t.icon&&(n.icon=t.icon),delete n.ref,delete n.folder,delete n.group,n}function O(e,{persisted:t,orphan:n=!1,node:r=null}){const s=e?.item||{};return{persisted:t,orphan:n,source:s.source||(s.core?"Built in":""),folder:e?.folder||TOOLS_FOLDER_KEY,defaults:n?{text:null,icon:null}:{text:s.text||"",icon:s.icon||""},...n&&r&&{label:r.text||r.url},...(e?.group||e?.item?.group)&&{group:e.group||e.item.group}}}export function placeTools(e,t,{forEditor:n=!1}={}){const r=w(e),s=(t||[]).filter(o=>o&&o.item&&typeof o.item.text=="string"),d=new Map;for(const o of s)o.item.url&&!d.has(o.item.url)&&d.set(o.item.url,o);const y=new Set,m=o=>{const i=[];for(const u of o){const h=u.url?d.get(u.url):null;if(h&&!y.has(u.url)){y.add(u.url);const g=b(u,h.item);Array.isArray(u.items)&&u.items.length?g.items=m(u.items):n&&(g.items=[]),n&&(g.$tool=O(h,{persisted:!0})),i.push(g);continue}if(u.ref){if(!n)continue;const{ref:g,...f}=u;i.push({...f,items:m(u.items||[]),$tool:O(null,{persisted:!0,orphan:!0,node:u})});continue}Array.isArray(u.items)&&(u.items=m(u.items)),i.push(u)}return i},a=m(r),T=new Set,S=o=>{for(const i of o||[])i.hidden&&i.text&&!i.url&&!(Array.isArray(i.items)&&i.items.length)&&T.add(c(i.text)),S(i.items)};S(a);let x=null;const L=()=>{const o=findFolder(a,TOOLS_FOLDER_KEY);if(o)return o;if(!x){x={text:TOOLS_FOLDER_TEXT,key:TOOLS_FOLDER_KEY,icon:"tool",items:[],synthesised:!0};const i=a.findIndex(u=>["system","documentation"].includes(c(u?.key||u?.text)));a.splice(i===-1?a.length:i,0,x)}return x},l=new Set,k=(o,i)=>{const u=groupKeyFor(o.key||slugify(o.text),i),h=$(a,p=>A(p)&&c(p.key)===u)||(o.items||[]).find(p=>A(p)&&(!p.key||c(p.key)===u)&&c(p.text)===c(i));if(h)return h;const g={text:String(i),key:u,icon:GROUP_ICON,items:[],synthesised:!0},f=o.items.findIndex(p=>l.has(p));return o.items.splice(f===-1?o.items.length:f,0,g),g};for(const o of s){const{item:i}=o;if(i.url&&y.has(i.url))continue;i.url&&y.add(i.url);const u=T.has(c(i.text));if(u&&!n)continue;const h=b({},i);n&&(h.items=[],h.$tool=O(o,{persisted:!1}),u&&(h.hidden=!0));let f=(o.parent?findFolder(a,o.parent)||$(a,_=>!P(_)&&c(_.text)===c(o.parent)):null)||findFolder(a,o.folder||i.folder)||L();f.items=Array.isArray(f.items)?f.items:[];const p=typeof(o.group||i.group)=="string"?(o.group||i.group).trim():"";p&&slugify(p)&&(f=k(f,p),f.items=Array.isArray(f.items)?f.items:[]),f.items.push(h),l.add(h)}return a}export const LOCKED_TOOLS=[{folder:"overview",item:{text:"Dashboard",url:"#/",icon:"home"}},{folder:"content",item:{text:"Site menus",url:"#/menus",icon:"menu",permission:"menus"}},{folder:"system",item:{text:"Users",url:"#/users",icon:"users",permission:"users"}}];export function isLockedUrl(e){return LOCKED_TOOLS.some(t=>t.item.url===e)}export function ensureLockedTools(e,{leaveInHiddenFolders:t=!1}={}){const n=w(e);for(const{folder:r,item:s}of LOCKED_TOOLS){const d=I(n,l=>l?.url===s.url),y=d.find(l=>!l.some(k=>k.restricted));if(!y&&d.length)continue;const m=y?.at(-1);if(m){if(m.hidden&&delete m.hidden,!y.slice(0,-1).some(o=>o.hidden)||t)continue;const k=y.at(-2);k.items=k.items.filter(o=>o!==m)}const a=c(r),T=I(n,l=>A(l)&&c(l.key)===a).filter(U),S=I(n,l=>A(l)&&!l.key&&c(l.text)===a).filter(U),x=(T[0]||S[0])?.at(-1)||null,L=m||{...s};x?(x.items=Array.isArray(x.items)?x.items:[],x.items.push(L)):n.push(L)}return n}export function insertFoldersBeforeSystem(e,t){const n=(t||[]).filter(Boolean);if(!n.length)return e;const r=["system","documentation"],s=e.findIndex(d=>r.includes(c(d?.key||d?.text)));return s===-1?[...e,...n]:[...e.slice(0,s),...n,...e.slice(s)]}export function canAny(e,t){return t?!e||!e.length?!1:e.includes(t)?!0:["read","create","update","delete"].some(n=>e.includes(`${t}.${n}`)):!0}export function filterByPermission(e,t,n=null){const r=[];for(const s of e){if(s.hidden||s.permission&&!canAny(t,s.permission))continue;const d=typeof s.url=="string"?s.url:"";if(n&&d.startsWith("#/")&&!F(n,d.slice(1)))continue;const y=Array.isArray(s.items)&&s.items.length>0,m=y?filterByPermission(s.items,t,n):[];y&&!m.length&&!d||r.push({...s,items:m})}return r}export function sidebarStateKey(e,t=null){const n=String(e?.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),r=String(e?.url||"").match(/^#\/projects\/([^/]+)/);let s="";if(r)try{s=`project-${decodeURIComponent(r[1])}.`}catch{s=`project-${r[1]}.`}return t?e?.key?`sidebar.expanded.key.${c(e.key)}`:`${t}/${s}${n}`:`sidebar.expanded.${s}${n}`}export function pruneEmptyToolsFolder(e){return(e||[]).filter(t=>A(t)?!(c(t.key)===TOOLS_FOLDER_KEY||!t.key&&t.text===TOOLS_FOLDER_TEXT)||Array.isArray(t.items)&&t.items.length>0:!0)}
|
|
@@ -90,6 +90,12 @@
|
|
|
90
90
|
"url": "#/content-api",
|
|
91
91
|
"icon": "file-text",
|
|
92
92
|
"permission": "settings"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"text": "Search",
|
|
96
|
+
"url": "#/search",
|
|
97
|
+
"icon": "search",
|
|
98
|
+
"permission": "settings"
|
|
93
99
|
}
|
|
94
100
|
],
|
|
95
101
|
"key": "content"
|
|
@@ -173,22 +179,11 @@
|
|
|
173
179
|
"icon": "layout",
|
|
174
180
|
"permission": "layouts"
|
|
175
181
|
},
|
|
176
|
-
{
|
|
177
|
-
"text": "My Profile",
|
|
178
|
-
"url": "#/my-profile",
|
|
179
|
-
"icon": "user"
|
|
180
|
-
},
|
|
181
182
|
{
|
|
182
183
|
"text": "API Tokens",
|
|
183
184
|
"url": "#/api-tokens",
|
|
184
185
|
"icon": "key",
|
|
185
186
|
"permission": "api-tokens"
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
"text": "Search",
|
|
189
|
-
"url": "#/search",
|
|
190
|
-
"icon": "search",
|
|
191
|
-
"permission": "settings"
|
|
192
187
|
}
|
|
193
188
|
],
|
|
194
189
|
"key": "system"
|
|
@@ -356,6 +356,7 @@ roles and notifications are stamped with it). The sections below cover the main
|
|
|
356
356
|
| `registerNotificationSource({id, label, severity, defaultOn, every, atBoot, check, audience})` | A switchable notification source, optionally scheduled (`boot`, `15m`, `hourly`, `daily`) (0.80.0) |
|
|
357
357
|
| `registerLoginCheck({id, phase, check, verify?})` | A sign-in check: `phase: 'before'` (e.g. lockout, before the password is checked) or `'after'` (e.g. two-factor, can ask one more question) (0.83.0) |
|
|
358
358
|
| `registerPasswordCheck({id, check})` | A password rule applied wherever a password is set (0.83.0) |
|
|
359
|
+
| `safeFetch(url, {method, headers, body, timeoutMs, maxBytes, maxRedirects, truncate, signal})` | Fetch an address a site user chose, under the site's outbound rules (`services/outbound.js`): public addresses only, the connection pinned to the checked address, redirects re-checked, ports 80/443; `outboundAllowPrivate` applies on a self-hosted site and never on a signed-only one. Limits default to 1 MB / 15 s / 3 redirects and may be raised to 25 MB / 60 s / 5. Resolves `{ok, status, headers, buffer, text(), json(), url, truncated}`; a refusal rejects with `err.code === 'EREFUSED_SSRF'`. `body` is a string, Buffer or Uint8Array and `headers` a plain object or `Headers`; anything else rejects with a TypeError rather than going out empty. Use it instead of `fetch` for any user-chosen URL. Call it with `?.` - older cores lack it (1.10.31) |
|
|
359
360
|
| `registerComponent({name, source})` | Add a Domma component from a `.dmc` source string, used on pages as `<dm-name>` or `[component]`; it is listed read-only in Components (0.92.2) |
|
|
360
361
|
|
|
361
362
|
`onEnable` / `onDisable` exports receive `{fastify, services, hooks}` where `hooks` holds only the role and notification
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -338,7 +338,7 @@ try {
|
|
|
338
338
|
item: {text: 'Theme', url: '#/theme', icon: 'droplet', permission: 'theme'}
|
|
339
339
|
});
|
|
340
340
|
await ensureSidebarItem({
|
|
341
|
-
groupText: '
|
|
341
|
+
groupText: 'Content',
|
|
342
342
|
item: {text: 'Search', url: '#/search', icon: 'search', permission: 'settings'}
|
|
343
343
|
});
|
|
344
344
|
// Restructure Documentation: Tutorials → submenu, add Components reference set.
|
|
@@ -699,11 +699,10 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Roles',
|
|
|
699
699
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Users', url: '#/users', icon: 'users', permission: 'users', countUrl: '/api/users/_count', inventory: true}});
|
|
700
700
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings', countUrl: '/api/settings/_count'}});
|
|
701
701
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings', countUrl: '/api/effects/_count', inventory: true}});
|
|
702
|
-
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'My Profile', url: '#/my-profile', icon: 'user', countUrl: '/api/auth/me/_count'}});
|
|
703
702
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts', countUrl: '/api/layouts/_count'}});
|
|
704
703
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
|
|
705
704
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
|
|
706
|
-
registerSidebarItem({folder: '
|
|
705
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
|
|
707
706
|
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings', countUrl: '/api/content-api/_count', inventory: true}});
|
|
708
707
|
// Documentation pages added after the sidebar menu was seeded (0.93.1). The menu is
|
|
709
708
|
// seeded once, so new pages reach existing sites only by registering here. The two
|
|
@@ -392,3 +392,62 @@ export async function safeFetch(url, opts = {}) {
|
|
|
392
392
|
}
|
|
393
393
|
throw new FetchRefused('Too many redirects.');
|
|
394
394
|
}
|
|
395
|
+
|
|
396
|
+
/** The most a plugin may raise safeFetch's limits to. */
|
|
397
|
+
export const PLUGIN_FETCH_CAPS = Object.freeze({maxBytes: 25 * 1024 * 1024, timeoutMs: 60_000, maxRedirects: 5});
|
|
398
|
+
|
|
399
|
+
/** A Headers instance or a plain object, as a plain object; anything else is refused. */
|
|
400
|
+
function pluginHeaders(h) {
|
|
401
|
+
if (h == null) return undefined;
|
|
402
|
+
if (typeof Headers !== 'undefined' && h instanceof Headers) return Object.fromEntries(h.entries());
|
|
403
|
+
if (typeof h === 'object' && !Array.isArray(h)) return h;
|
|
404
|
+
throw new TypeError('safeFetch: headers must be an object or a Headers instance.');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A string, Buffer or byte array; anything else is refused rather than sent empty. */
|
|
408
|
+
function pluginBody(b) {
|
|
409
|
+
if (b == null) return undefined;
|
|
410
|
+
if (typeof b === 'string' || Buffer.isBuffer(b)) return b;
|
|
411
|
+
if (b instanceof Uint8Array) return Buffer.from(b.buffer, b.byteOffset, b.byteLength);
|
|
412
|
+
throw new TypeError('safeFetch: body must be a string, Buffer or Uint8Array.');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The options a plugin's safeFetch call keeps: the request's own. `policy`,
|
|
417
|
+
* `lookup` and `own` are dropped - the site's policy is always read fresh (so
|
|
418
|
+
* `outboundAllowPrivate` still applies on a self-hosted site and is still
|
|
419
|
+
* ignored on a signed-only one) and a plugin cannot hand in its own resolver.
|
|
420
|
+
* The limits may be raised up to PLUGIN_FETCH_CAPS, never past them.
|
|
421
|
+
*
|
|
422
|
+
* @param {object} [opts] - method, headers, body, timeoutMs, maxBytes, maxRedirects, truncate, signal
|
|
423
|
+
* @returns {object}
|
|
424
|
+
*/
|
|
425
|
+
export function pluginFetchOptions(opts = {}) {
|
|
426
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
427
|
+
const capped = (key) => {
|
|
428
|
+
const n = Number(o[key]);
|
|
429
|
+
return o[key] != null && Number.isFinite(n) && n >= 0 ? Math.min(n, PLUGIN_FETCH_CAPS[key]) : undefined;
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
method: typeof o.method === 'string' ? o.method : undefined,
|
|
433
|
+
headers: pluginHeaders(o.headers),
|
|
434
|
+
body: pluginBody(o.body),
|
|
435
|
+
timeoutMs: capped('timeoutMs'),
|
|
436
|
+
maxBytes: capped('maxBytes'),
|
|
437
|
+
maxRedirects: capped('maxRedirects'),
|
|
438
|
+
truncate: o.truncate === true,
|
|
439
|
+
signal: o.signal instanceof AbortSignal ? o.signal : undefined
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `hooks.safeFetch(url, opts)` as plugins get it - see pluginFetchOptions().
|
|
445
|
+
* A refusal rejects with FetchRefused (`err.code === 'EREFUSED_SSRF'`); a body or
|
|
446
|
+
* headers of the wrong type reject with a TypeError.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} url
|
|
449
|
+
* @param {object} [opts]
|
|
450
|
+
*/
|
|
451
|
+
export async function pluginSafeFetch(url, opts = {}) {
|
|
452
|
+
return safeFetch(String(url), pluginFetchOptions(opts));
|
|
453
|
+
}
|
|
@@ -39,6 +39,7 @@ import {registerThemeProvider} from './themeSettings.js';
|
|
|
39
39
|
import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
40
40
|
import {notifierFor} from './notify.js';
|
|
41
41
|
import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
|
|
42
|
+
import {pluginSafeFetch} from './outbound.js';
|
|
42
43
|
import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
|
|
43
44
|
import {createCollection, getCollection} from './collections.js';
|
|
44
45
|
import * as defaultRolesService from './roles.js';
|
|
@@ -570,7 +571,9 @@ export async function registerPlugins(fastify) {
|
|
|
570
571
|
// Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
|
|
571
572
|
registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
|
|
572
573
|
// Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
|
|
573
|
-
registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn)
|
|
574
|
+
registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn),
|
|
575
|
+
// Fetching an address a site user chose (services/outbound.js) - the site's policy, never the plugin's.
|
|
576
|
+
safeFetch: pluginSafeFetch},
|
|
574
577
|
settings,
|
|
575
578
|
config: {}
|
|
576
579
|
});
|
|
@@ -41,7 +41,8 @@ const SEED_ITEMS = [
|
|
|
41
41
|
{text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
|
|
42
42
|
{text: 'Components', url: '#/components', icon: 'component', permission: 'components'},
|
|
43
43
|
{text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
|
|
44
|
-
{text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings'}
|
|
44
|
+
{text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings'},
|
|
45
|
+
{text: 'Search', url: '#/search', icon: 'search', permission: 'settings'}
|
|
45
46
|
]
|
|
46
47
|
},
|
|
47
48
|
{
|
|
@@ -65,11 +66,9 @@ const SEED_ITEMS = [
|
|
|
65
66
|
{text: 'Users', url: '#/users', icon: 'users', permission: 'users'},
|
|
66
67
|
{text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings'},
|
|
67
68
|
{text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
|
|
68
|
-
{text: 'Search', url: '#/search', icon: 'search', permission: 'settings'},
|
|
69
69
|
{text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
|
|
70
70
|
{text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
|
|
71
|
-
{text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'}
|
|
72
|
-
{text: 'My Profile', url: '#/my-profile', icon: 'user'}
|
|
71
|
+
{text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'}
|
|
73
72
|
]
|
|
74
73
|
},
|
|
75
74
|
{
|
|
@@ -441,6 +440,35 @@ export async function ensureFolderKeys(opts = {}) {
|
|
|
441
440
|
changed = true;
|
|
442
441
|
}
|
|
443
442
|
|
|
443
|
+
// Account access lives in the top menu. Move Search out of the old
|
|
444
|
+
// System default once, preserving saved labels, permissions and folders.
|
|
445
|
+
if (!menu.meta?.accountSearchNavigation && items.length) {
|
|
446
|
+
const moving = [];
|
|
447
|
+
const removeProfile = nodes => nodes.filter(n => n?.url !== '#/my-profile').map(n => {
|
|
448
|
+
if (Array.isArray(n?.items)) n.items = removeProfile(n.items);
|
|
449
|
+
return n;
|
|
450
|
+
});
|
|
451
|
+
for (const folder of items.filter(n => isFolder(n) && n.key === 'system')) {
|
|
452
|
+
moving.push(...(folder.items || []).filter(n => n?.url === '#/search'));
|
|
453
|
+
folder.items = removeProfile((folder.items || []).filter(n => n?.url !== '#/search'));
|
|
454
|
+
}
|
|
455
|
+
const hasSearch = nodes => nodes.some(n => n?.url === '#/search'
|
|
456
|
+
|| (Array.isArray(n?.items) && hasSearch(n.items)));
|
|
457
|
+
let content = items.find(n => isFolder(n) && n.key === 'content');
|
|
458
|
+
for (const item of moving) {
|
|
459
|
+
if (hasSearch(items)) break;
|
|
460
|
+
if (!content) {
|
|
461
|
+
content = {text: 'Content', key: 'content', icon: 'edit', items: []};
|
|
462
|
+
const index = items.findIndex(n => isFolder(n) && n.key === 'system');
|
|
463
|
+
items.splice(index === -1 ? items.length : index, 0, content);
|
|
464
|
+
}
|
|
465
|
+
if (!Array.isArray(content.items)) content.items = [];
|
|
466
|
+
content.items.push(item);
|
|
467
|
+
}
|
|
468
|
+
menu.meta = {...(menu.meta || {}), accountSearchNavigation: true};
|
|
469
|
+
changed = true;
|
|
470
|
+
}
|
|
471
|
+
|
|
444
472
|
// An empty menu is left empty: the renderer's minimal fallback (Dashboard,
|
|
445
473
|
// Menus) is what an admin needs to repair it, and a lone Tools folder would
|
|
446
474
|
// stop that fallback from applying.
|