domma-cms 1.8.12 → 1.8.13
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 +4 -0
- package/CLAUDE.md +13 -0
- package/admin/js/views/plugins.js +1 -1
- package/docs/configuration.md +2 -0
- package/package.json +1 -1
- package/server/routes/api/plugins.js +2 -2
- package/server/routes/api/sidebar.js +3 -0
- package/server/routes/api/tools.js +20 -6
- package/server/server.js +14 -8
- package/server/services/plugins.js +11 -5
- package/server/services/staffAccess.js +130 -0
- package/server/services/tools.js +44 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.13] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- **A built-in Tool can be off for a site but kept for its host's staff.** `config/tools.json` takes `"staff": true` beside `"enabled": false`: the Tool is switched off for everyone on the site - its owner included - but its screen, sidebar entry and API stay open to a staff session that the site's Domma CMS Manager opens (`/_staff/open`, a one-time ticket signed with the site's own key, valid for one minute; the session lasts an hour). Domma CMS Manager uses this for Import from WordPress on the sites it hosts. Nothing changes for a site that does not set it, a site not run by a manager never accepts a ticket, and WordPress's old addresses (`/?p=12`) keep answering whatever the switch says.
|
|
6
|
+
|
|
3
7
|
## [1.8.12] - 2026-10-16
|
|
4
8
|
|
|
5
9
|
- **Import from WordPress asks before changing a home page an earlier import set.** Until now the home page was set by the first import only, and a later import - of another WordPress site, or of the same one after its home page changed - quietly left it alone. Now, when an earlier import set the home page and this export's home page is a different page, the preview's Home page card asks, for example, "Make 'Welcome' the home page instead of 'Home'?". There is no default: Run import stays greyed out, with the reason beside it, until you choose. Yes does what the first import did - the current home page moves to /previous-home (or -2, -3…) as a draft, nothing is deleted, and the new page's old addresses lead to /. No imports the page at its own address and leaves the home page as it is. Running the same export again with the same home page asks nothing. A run started without the answer (from the API, say) is refused with a plain message.
|
package/CLAUDE.md
CHANGED
|
@@ -197,6 +197,16 @@ notification source, `todo` calendar source and analytics beacon are left out at
|
|
|
197
197
|
Manager's site-manager (`services/toolSwitches.js`) runs the same function against a site on disk: keep it free of
|
|
198
198
|
I/O. `PUT /api/tools/:name` and `PUT /api/plugins/:name` answer 409 + `plan` when a switch moves anything else and
|
|
199
199
|
apply on the repeat with `cascade: true`; `locked: true` (tools.json or plugins.json) is the manager's - 423 here.
|
|
200
|
+
**Staff-only (`staff: true`).** A built-in Tool that is off with `staff: true` stays open to a STAFF SESSION and nobody
|
|
201
|
+
else - not even the site's own level-0 role (hosted sites' owners are super-admin). How the Manager ships the WordPress
|
|
202
|
+
importer on hosted sites until it is gifted (`{enabled: false, locked: true, staff: true}`). A staff session is an access
|
|
203
|
+
token with `dcmStaff: true` and `site` = this process's `SITE_SLUG`, made only by `GET /_staff/open?ticket=…`
|
|
204
|
+
(`services/staffAccess.js`) from a one-time, 60-second ticket the Manager signs with the site's JWT_SECRET; an unmanaged
|
|
205
|
+
site (no SITE_SLUG) refuses every ticket. `isDcmStaff(request.user)` is the one test - `toolGate` (`isStaffRequest`),
|
|
206
|
+
`/api/tools/enabled`, `/api/plugins/admin-config`'s `tools` and the sidebar's `staffOnly` entries all use it via
|
|
207
|
+
`toolOpenAt(state, staff)`. Never let a site-made token or user field carry `dcmStaff` (login signs a whitelisted
|
|
208
|
+
`safeUser`). Boot-time consumers (notification/calendar sources, SEO's engine, the analytics beacon) still treat the
|
|
209
|
+
Tool as OFF, and so does `planSwitch()`.
|
|
200
210
|
|
|
201
211
|
`CORE_PLUGINS` in `plugins.js` is now **empty**. It is a waiting room, not a destination: a plugin
|
|
202
212
|
sits there while its replacement is built, then leaves.
|
|
@@ -333,6 +343,9 @@ A built-in Tool (`wordpress-import` in `services/tools.js`, permission `import.w
|
|
|
333
343
|
`admin`), `#/wordpress-import`. Server: `server/services/wpImport/` + `routes/api/wordpress-import.js`
|
|
334
344
|
(`/api/import/wordpress`). Upload a WXR export -> preview (`planImport()`, pure, in `plan.js`) -> a background job
|
|
335
345
|
(`startRun()`, yields between items) -> a report kept in `content/imports/wordpress/` (history, reports, `map.json`).
|
|
346
|
+
On hosted sites the Manager keeps it staff-only (above) unless it gifts it for 30 days; `?p=` redirects and reports
|
|
347
|
+
are untouched by the switch. The Manager reads `content/imports/wordpress/running.json` to avoid switching it off (or
|
|
348
|
+
restarting) mid-run - keep that file's name and meaning.
|
|
336
349
|
|
|
337
350
|
- **The XML reader is its own** (`xml.js`): any DOCTYPE or ENTITY is refused, only the predefined and numeric
|
|
338
351
|
entities decode - do not swap in a parser that expands entities. Zips (`zip.js`) are read by offset with
|
|
@@ -34,7 +34,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
|
|
|
34
34
|
<strong data-bind-text="displayName"></strong>
|
|
35
35
|
<span class="plx-chip" data-bind-class="stateCls" data-bind-text="stateText"></span>
|
|
36
36
|
<small class="plx-hint" data-bind-hidden="!reason" data-bind-text="reason"></small>
|
|
37
|
-
</li>`,d=T=>String(T??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function be(T){const m=document.createElement("template");return m.innerHTML=T.trim(),m.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(T){v.reset();const m=T.get(0).querySelector(".plx")||T.get(0),k=()=>qe.test(location.hash)&&m.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),h=M.observable({available:null,site:null,items:[]}),he=M.observable(!0),ne=M.observable(""),b=M.observable("");let fe="all";try{fe=ot.includes(S.get(pe))?S.get(pe):"all"}catch{}const N=M.observable(fe),V=M.observable(""),j=M.observable("all"),ve=M.observable("");v.add(M.effect(()=>{const e=N.value;try{S.set(pe,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:N.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>mt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(e)==="update").length),Pe=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:ve.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(m.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{he.value=!1}}async function ge(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ge()]),st(yt)};async function B(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||b.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.plugins.update(e.name,{enabled:t,cascade:i});b.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let i;try{i=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ht(c.body.plan);if(b.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;b.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(i,{onStatus:c=>{b.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(i){E.toast(`Not switched ${n}: ${i.message||i}`,{type:"error"})}finally{b.value="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${d(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await x.marketplace.install(e.slug,e.version,{update:t}),i=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${i} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await P()}}function ye(e){e?.buyUrl&&(window.open(e.buyUrl,"_blank","noopener"),E.toast(`Manage my site opened in a new tab. We email you a sign-in link; ${e.displayName||e.slug} is ticked ready once you are in. Install it here when it is paid for.`,{type:"info",duration:9e3}))}async function we(e,t){const n=W(e)?.tier==="paid"?` <p class="text-muted">It is a paid Tool: your site's manager is told, and if you pay for it on its own it comes off your monthly bill (the rest of this month is credited).</p>`:"";if(await E.confirm(`<p>Uninstall ${d(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>${n}`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{const i=(await x.marketplace.uninstall(e))?.release?.removed?" It is off your bill.":"";E.toast(`${t||e} uninstalled.${i} Restart the server to apply.`,{type:"success",duration:6e3})}catch(l){E.toast(`Uninstall failed: ${l.message||l}`,{type:"error"})}k()&&await P()}}async function xe(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ke=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=m.querySelector(".plx-file");async function _e(e){if(b.peek())return;b.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await x.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(b.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${d(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${d(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}b.value=`Installing ${e.name}\u2026`}}finally{b.value="",k()&&await P()}}const $e=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",$e),v.add(()=>z.removeEventListener("change",$e));const G=new Set;let U=null,Ee=!1;function Se({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,O=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(O)return;O=!0;const q=await E.confirm("Discard the changes you have not saved?");if(O=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),O=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",bt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const A=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${A}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${H}<input id="${A}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${A}" class="form-input" ${K}>${s.options.map(Ae=>`<option value="${d(Ae.value)}">${d(Ae.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${A}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${A}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${A}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=be(`
|
|
37
|
+
</li>`,d=T=>String(T??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function be(T){const m=document.createElement("template");return m.innerHTML=T.trim(),m.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(T){v.reset();const m=T.get(0).querySelector(".plx")||T.get(0),k=()=>qe.test(location.hash)&&m.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":"",e.staff?"your host's staff can still use it":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),h=M.observable({available:null,site:null,items:[]}),he=M.observable(!0),ne=M.observable(""),b=M.observable("");let fe="all";try{fe=ot.includes(S.get(pe))?S.get(pe):"all"}catch{}const N=M.observable(fe),V=M.observable(""),j=M.observable("all"),ve=M.observable("");v.add(M.effect(()=>{const e=N.value;try{S.set(pe,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:N.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>mt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(e)==="update").length),Pe=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:ve.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(m.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{he.value=!1}}async function ge(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ge()]),st(yt)};async function B(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||b.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.plugins.update(e.name,{enabled:t,cascade:i});b.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let i;try{i=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ht(c.body.plan);if(b.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;b.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(i,{onStatus:c=>{b.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(i){E.toast(`Not switched ${n}: ${i.message||i}`,{type:"error"})}finally{b.value="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${d(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await x.marketplace.install(e.slug,e.version,{update:t}),i=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${i} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await P()}}function ye(e){e?.buyUrl&&(window.open(e.buyUrl,"_blank","noopener"),E.toast(`Manage my site opened in a new tab. We email you a sign-in link; ${e.displayName||e.slug} is ticked ready once you are in. Install it here when it is paid for.`,{type:"info",duration:9e3}))}async function we(e,t){const n=W(e)?.tier==="paid"?` <p class="text-muted">It is a paid Tool: your site's manager is told, and if you pay for it on its own it comes off your monthly bill (the rest of this month is credited).</p>`:"";if(await E.confirm(`<p>Uninstall ${d(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>${n}`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{const i=(await x.marketplace.uninstall(e))?.release?.removed?" It is off your bill.":"";E.toast(`${t||e} uninstalled.${i} Restart the server to apply.`,{type:"success",duration:6e3})}catch(l){E.toast(`Uninstall failed: ${l.message||l}`,{type:"error"})}k()&&await P()}}async function xe(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ke=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=m.querySelector(".plx-file");async function _e(e){if(b.peek())return;b.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await x.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(b.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${d(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${d(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}b.value=`Installing ${e.name}\u2026`}}finally{b.value="",k()&&await P()}}const $e=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",$e),v.add(()=>z.removeEventListener("change",$e));const G=new Set;let U=null,Ee=!1;function Se({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,O=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(O)return;O=!0;const q=await E.confirm("Discard the changes you have not saved?");if(O=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),O=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",bt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const A=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${A}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${H}<input id="${A}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${A}" class="form-input" ${K}>${s.options.map(Ae=>`<option value="${d(Ae.value)}">${d(Ae.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${A}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${A}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${A}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=be(`
|
|
38
38
|
<div class="plx-form">
|
|
39
39
|
<p class="plx-desc"></p>
|
|
40
40
|
<dl class="plx-facts"></dl>
|
package/docs/configuration.md
CHANGED
|
@@ -1328,6 +1328,8 @@ The built-in Tools that can be switched off: `contacts`, `notes`, `todo`, `analy
|
|
|
1328
1328
|
|
|
1329
1329
|
A Tool that is off keeps its data. Its API answers 404, its sidebar entry, notification and calendar sources go, and (for `analytics`) the page-view beacon is not emitted. Switching one off also switches off any enabled plugin that `requires` it - see `server/services/tools.js`.
|
|
1330
1330
|
|
|
1331
|
+
`"staff": true` beside `"enabled": false` keeps a Tool open to a staff session opened by the site's Domma CMS Manager (`/_staff/open`, services/staffAccess.js) and to nobody else, the site's own top role included. Domma CMS Manager sets `{"enabled": false, "locked": true, "staff": true}` for `wordpress-import` on the sites it hosts.
|
|
1332
|
+
|
|
1331
1333
|
---
|
|
1332
1334
|
|
|
1333
1335
|
## `config/connections.json` (Pro)
|
package/package.json
CHANGED
|
@@ -371,8 +371,8 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
371
371
|
});
|
|
372
372
|
|
|
373
373
|
// Return merged admin config (sidebar, routes, views) for enabled plugins
|
|
374
|
-
fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async () => {
|
|
375
|
-
return getAdminPluginConfig();
|
|
374
|
+
fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async (request) => {
|
|
375
|
+
return getAdminPluginConfig(request.user);
|
|
376
376
|
});
|
|
377
377
|
|
|
378
378
|
// ── In-admin code editor (plugins.develop - code execution grade) ──────
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
18
18
|
*/
|
|
19
19
|
import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
|
|
20
|
+
import {isDcmStaff} from '../../services/staffAccess.js';
|
|
20
21
|
import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
21
22
|
import {filterItemsForUser, getMenu} from '../../services/menus.js';
|
|
22
23
|
import {isDashboardPro} from '../../services/widgets.js';
|
|
@@ -60,6 +61,8 @@ export async function sidebarRoutes(fastify, opts = {}) {
|
|
|
60
61
|
? {...entry, item: {...entry.item, text: boardLabel}}
|
|
61
62
|
: entry))
|
|
62
63
|
.filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
|
|
64
|
+
// A staff-only Tool's entry (services/tools.js `staff`): the Manager's staff sessions only.
|
|
65
|
+
.filter(entry => !entry?.item?.staffOnly || isDcmStaff(request.user))
|
|
63
66
|
.map(entry => Array.isArray(entry.item?.items)
|
|
64
67
|
? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
|
|
65
68
|
: entry);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Built-in Tool switches - Contacts, Notes, Todo, Analytics (services/tools.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/tools - each built-in Tool, on/off, locked, and the plugins that depend on it (plugins.read)
|
|
5
|
-
* GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off)
|
|
5
|
+
* GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off);
|
|
6
|
+
* a staff-only Tool (`staff: true`, services/tools.js) is true for a staff session only
|
|
6
7
|
* PUT /api/tools/:name - {enabled, cascade?} (plugins.update). Answers 423 when the site's manager has
|
|
7
8
|
* locked it, and 409 with the plan when the switch moves other Tools too - repeat
|
|
8
9
|
* with `cascade: true` to apply. Restarts the site like a plugin switch does.
|
|
@@ -12,7 +13,10 @@ import {
|
|
|
12
13
|
requirePermission as defaultRequirePermission
|
|
13
14
|
} from '../../middleware/auth.js';
|
|
14
15
|
import {discoverPlugins, getPluginStates, runLifecycleHook, savePluginState} from '../../services/plugins.js';
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
CORE_TOOLS, getToolStates, isCoreTool, isToolEnabled, isToolStaffOnly, planSwitch, saveToolState, switchableUnits, toolOpenAt
|
|
18
|
+
} from '../../services/tools.js';
|
|
19
|
+
import {isDcmStaff, isStaffRequest} from '../../services/staffAccess.js';
|
|
16
20
|
import {autoRestartEnabled, hasSupervisor, scheduleRestart} from './plugins.js';
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -39,15 +43,22 @@ export async function applyCascade(cascaded, fastify) {
|
|
|
39
43
|
* the Tool's prefix; the inner routes must NOT be given it again, or every
|
|
40
44
|
* route lands at /api/notes/api/notes/... (0.88.0 did exactly that on dcm).
|
|
41
45
|
*
|
|
46
|
+
* Off with `staff: true` (services/tools.js) lets a staff session through
|
|
47
|
+
* (services/staffAccess.js) and nobody else - not the site's own top role;
|
|
48
|
+
* the routes' own authenticate/permission checks still run.
|
|
49
|
+
*
|
|
42
50
|
* @param {string} name the Tool (services/tools.js CORE_TOOLS)
|
|
43
51
|
* @param {Function} routes its Fastify plugin
|
|
44
52
|
* @param {(name: string) => boolean} [isEnabled]
|
|
53
|
+
* @param {{isStaffOnly?: (name: string) => boolean, staffPass?: (request: object) => Promise<boolean>}} [opts]
|
|
45
54
|
*/
|
|
46
|
-
export function toolGate(name, routes, isEnabled = isToolEnabled) {
|
|
55
|
+
export function toolGate(name, routes, isEnabled = isToolEnabled, {isStaffOnly = isToolStaffOnly, staffPass = isStaffRequest} = {}) {
|
|
47
56
|
const displayName = CORE_TOOLS.find(t => t.name === name)?.displayName || name;
|
|
48
57
|
return async (scope) => {
|
|
49
58
|
scope.addHook('onRequest', async (request, reply) => {
|
|
50
|
-
if (
|
|
59
|
+
if (isEnabled(name)) return;
|
|
60
|
+
if (isStaffOnly(name) && await staffPass(request)) return;
|
|
61
|
+
return reply.code(404).send({error: `${displayName} is switched off on this site.`});
|
|
51
62
|
});
|
|
52
63
|
await scope.register(routes);
|
|
53
64
|
};
|
|
@@ -74,6 +85,8 @@ export async function toolsRoutes(fastify, opts = {}) {
|
|
|
74
85
|
...t,
|
|
75
86
|
enabled: u.enabled,
|
|
76
87
|
locked: u.locked,
|
|
88
|
+
// Off for the site, kept open to the Manager's staff sessions.
|
|
89
|
+
staff: u.staff === true,
|
|
77
90
|
// Enabled plugins that stop, or lose something, if this goes off.
|
|
78
91
|
requiredBy: plugins.filter(p => p.enabled && p.requires.includes(t.name)).map(p => ({name: p.name, displayName: p.displayName})),
|
|
79
92
|
usedBy: plugins.filter(p => p.enabled && t.name in p.uses).map(p => ({name: p.name, displayName: p.displayName, reason: p.uses[t.name]}))
|
|
@@ -81,9 +94,10 @@ export async function toolsRoutes(fastify, opts = {}) {
|
|
|
81
94
|
});
|
|
82
95
|
});
|
|
83
96
|
|
|
84
|
-
fastify.get('/tools/enabled', {preHandler: [authenticate]}, async () => {
|
|
97
|
+
fastify.get('/tools/enabled', {preHandler: [authenticate]}, async (request) => {
|
|
85
98
|
const states = getToolStates();
|
|
86
|
-
|
|
99
|
+
const staff = isDcmStaff(request.user);
|
|
100
|
+
return Object.fromEntries(CORE_TOOLS.map(t => [t.name, toolOpenAt(states[t.name], staff)]));
|
|
87
101
|
});
|
|
88
102
|
|
|
89
103
|
fastify.put('/tools/:name', canUpdate, async (request, reply) => {
|
package/server/server.js
CHANGED
|
@@ -578,7 +578,7 @@ await app.register(scaffoldRoutes, {prefix: '/api'});
|
|
|
578
578
|
// Each can be switched off (config/tools.json, services/tools.js): the gate is
|
|
579
579
|
// read per request, so an API stops answering the moment the switch is saved,
|
|
580
580
|
// before the restart that takes its sidebar entry and sources away.
|
|
581
|
-
const {isToolEnabled} = await import('./services/tools.js');
|
|
581
|
+
const {isToolEnabled, isToolStaffOnly} = await import('./services/tools.js');
|
|
582
582
|
const {toolGate, toolsRoutes} = await import('./routes/api/tools.js');
|
|
583
583
|
await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
|
|
584
584
|
await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
|
|
@@ -635,7 +635,13 @@ await app.register(relatedRoutes, {prefix: '/api/related'});
|
|
|
635
635
|
const {registerSidebarItem} = await import('./services/hooks.js');
|
|
636
636
|
// `countUrl` is the badge: the sidebar fetches it and shows {count, label, tone}
|
|
637
637
|
// (see fetchCounts in admin/js/lib/sidebar-renderer.js).
|
|
638
|
-
// A built-in Tool switched off gets no entry (its switch restarts the site)
|
|
638
|
+
// A built-in Tool switched off gets no entry (its switch restarts the site) - unless it is
|
|
639
|
+
// off with `staff: true` (the Manager's lock on hosted sites): then the entry is there for
|
|
640
|
+
// the Manager's staff sessions only (`staffOnly`, checked per viewer by /api/sidebar/registered-items).
|
|
641
|
+
const toolEntry = (name, item) => {
|
|
642
|
+
if (isToolEnabled(name)) registerSidebarItem({item: {source: 'Built in', ...item}});
|
|
643
|
+
else if (isToolStaffOnly(name)) registerSidebarItem({item: {source: 'Built in', ...item, staffOnly: true}});
|
|
644
|
+
};
|
|
639
645
|
const coreToolItems = [
|
|
640
646
|
{text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
|
|
641
647
|
{text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
|
|
@@ -644,12 +650,8 @@ const coreToolItems = [
|
|
|
644
650
|
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts', countUrl: '/api/contacts/count', inventory: true},
|
|
645
651
|
{text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
|
|
646
652
|
];
|
|
647
|
-
for (const item of coreToolItems)
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
if (isToolEnabled('wordpress-import')) {
|
|
651
|
-
registerSidebarItem({item: {source: 'Built in', text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true}});
|
|
652
|
-
}
|
|
653
|
+
for (const item of coreToolItems) toolEntry(item.permission, item);
|
|
654
|
+
toolEntry('wordpress-import', {text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true});
|
|
653
655
|
// Todo's to-dos on the calendar (registered when routes/api/todo.js loads).
|
|
654
656
|
if (!isToolEnabled('todo')) {
|
|
655
657
|
const {unregisterCalendarSource} = await import('./services/hooks.js');
|
|
@@ -747,6 +749,10 @@ await app.register(docsPublicRoutes);
|
|
|
747
749
|
// An instant call's page, /_call/<id> (a reserved prefix, like /_preview) - before the catch-all.
|
|
748
750
|
const { callPublicRoutes } = await import('./routes/call-public.js');
|
|
749
751
|
await app.register(callPublicRoutes);
|
|
752
|
+
// A staff session from the site's Manager (/_staff/open?ticket=…, services/staffAccess.js) - the one way
|
|
753
|
+
// into a Tool switched off with `staff: true`. A ticket only works on a site its Manager started.
|
|
754
|
+
const { staffAccessRoutes } = await import('./services/staffAccess.js');
|
|
755
|
+
await app.register(staffAccessRoutes);
|
|
750
756
|
|
|
751
757
|
// The outreach unsubscribe link (/campaign-unsubscribe) - core's, so it outlives the Tool that
|
|
752
758
|
// sent the email. After the plugins: an older Campaigns Pro that registers it itself wins.
|
|
@@ -45,10 +45,11 @@ import * as defaultRolesService from './roles.js';
|
|
|
45
45
|
import {getAdminScope, normaliseAdminScope} from './roles.js';
|
|
46
46
|
import {authenticateFor} from './pluginScope.js';
|
|
47
47
|
import {getEffectiveRoles} from './userRoles.js';
|
|
48
|
+
import {isDcmStaff} from './staffAccess.js';
|
|
48
49
|
import * as defaultUsersService from './users.js';
|
|
49
50
|
import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
|
|
50
51
|
import {invalidateTags as cacheInvalidateTags} from './cache/index.js';
|
|
51
|
-
import {CORE_TOOLS, getToolStates, unmetRequirements} from './tools.js';
|
|
52
|
+
import {CORE_TOOLS, getToolStates, toolOpenAt, unmetRequirements} from './tools.js';
|
|
52
53
|
|
|
53
54
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
54
55
|
|
|
@@ -1353,7 +1354,10 @@ export function viewSection(manifest, viewName) {
|
|
|
1353
1354
|
return text && text !== (manifest.displayName || manifest.name) ? text : '';
|
|
1354
1355
|
}
|
|
1355
1356
|
|
|
1356
|
-
|
|
1357
|
+
/**
|
|
1358
|
+
* @param {object} [viewer] request.user - a staff-only Tool (services/tools.js) is on for a staff session only
|
|
1359
|
+
*/
|
|
1360
|
+
export async function getAdminPluginConfig(viewer = null) {
|
|
1357
1361
|
const manifests = await discoverPlugins();
|
|
1358
1362
|
const states = getPluginStates();
|
|
1359
1363
|
|
|
@@ -1432,10 +1436,12 @@ export async function getAdminPluginConfig() {
|
|
|
1432
1436
|
}
|
|
1433
1437
|
}
|
|
1434
1438
|
|
|
1435
|
-
// Built-in Tools and whether each is on, so the admin can
|
|
1436
|
-
// switched-off one away (services/tools.js
|
|
1439
|
+
// Built-in Tools and whether each is on FOR THIS VIEWER, so the admin can
|
|
1440
|
+
// turn a link to a switched-off one away (services/tools.js; `staff: true`
|
|
1441
|
+
// keeps one open to the Manager's staff sessions).
|
|
1437
1442
|
const toolStates = getToolStates();
|
|
1438
|
-
const
|
|
1443
|
+
const staff = isDcmStaff(viewer);
|
|
1444
|
+
const tools = Object.fromEntries(CORE_TOOLS.map(t => [t.name, {displayName: t.displayName, enabled: toolOpenAt(toolStates[t.name], staff)}]));
|
|
1439
1445
|
|
|
1440
1446
|
return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers(), tools };
|
|
1441
1447
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff sessions - the hosting company's staff (the people at the site's
|
|
3
|
+
* Domma CMS Manager), as opposed to anyone the site itself has made.
|
|
4
|
+
*
|
|
5
|
+
* A Tool switched off with `staff: true` (services/tools.js) is open to a
|
|
6
|
+
* staff session only. A site's own users - its owner included, though the
|
|
7
|
+
* owner holds the top role - never are: a staff session is something only the
|
|
8
|
+
* Manager can mint, never a role or a user field the site could give itself.
|
|
9
|
+
*
|
|
10
|
+
* ## How a staff session starts
|
|
11
|
+
*
|
|
12
|
+
* The Manager signs a one-time TICKET with the site's own JWT_SECRET (as its
|
|
13
|
+
* Data Manager already does for its loopback calls) and sends the staff
|
|
14
|
+
* member's browser to `GET /_staff/open?ticket=…`:
|
|
15
|
+
*
|
|
16
|
+
* {type: 'staff-ticket', dcmStaff: true, site: <SITE_SLUG>, by, jti, to, exp: +60 s}
|
|
17
|
+
*
|
|
18
|
+
* This route checks the signature and expiry, that `site` is this site's
|
|
19
|
+
* SITE_SLUG (set only by the Manager that forked it - an unmanaged site has
|
|
20
|
+
* none and refuses every ticket), that `to` is a screen it opens, and that
|
|
21
|
+
* the ticket has not been used; then it hands the browser a one-hour access
|
|
22
|
+
* token `{id: 'dcm-staff', role: <top role>, type: 'access', dcmStaff: true,
|
|
23
|
+
* site}` and opens the admin at `to`. No refresh token: the session ends.
|
|
24
|
+
*
|
|
25
|
+
* Anyone who can read the site's JWT_SECRET (shell access, or code running in
|
|
26
|
+
* the site's process - which includes a level-0 user installing an unsigned
|
|
27
|
+
* plugin, unless server.json `requireSignedPlugins` is on) can mint either;
|
|
28
|
+
* that is true of every session. The session is the site's full top role for
|
|
29
|
+
* an hour (the Manager can already act as it), with no `sid` to revoke it.
|
|
30
|
+
*
|
|
31
|
+
* @module services/staffAccess
|
|
32
|
+
*/
|
|
33
|
+
import {getRoleHierarchy} from './roles.js';
|
|
34
|
+
|
|
35
|
+
/** Screens a staff ticket may open. */
|
|
36
|
+
export const STAFF_SCREENS = Object.freeze(['#/wordpress-import']);
|
|
37
|
+
export const STAFF_SESSION_SECONDS = 3600;
|
|
38
|
+
const STAFF_ID = 'dcm-staff';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is this decoded token (request.user) a staff session for this site?
|
|
42
|
+
*
|
|
43
|
+
* @param {object|null|undefined} user
|
|
44
|
+
* @param {string|undefined} [siteSlug] process.env.SITE_SLUG - unset on an unmanaged site
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isDcmStaff(user, siteSlug = process.env.SITE_SLUG) {
|
|
48
|
+
return Boolean(siteSlug) && user?.type === 'access' && user?.dcmStaff === true && user?.site === siteSlug;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a request carries a staff session. Never answers: a missing or bad
|
|
53
|
+
* token is "no".
|
|
54
|
+
*
|
|
55
|
+
* @param {import('fastify').FastifyRequest} request
|
|
56
|
+
* @returns {Promise<boolean>}
|
|
57
|
+
*/
|
|
58
|
+
export async function isStaffRequest(request) {
|
|
59
|
+
try {
|
|
60
|
+
const decoded = await request.jwtVerify();
|
|
61
|
+
return isDcmStaff(decoded);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** jti -> expiry (ms): tickets already used. */
|
|
68
|
+
const used = new Map();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check a ticket. Pure apart from the used-ticket list.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} decoded the verified ticket payload
|
|
74
|
+
* @param {{siteSlug?: string, now?: number}} [opts]
|
|
75
|
+
* @returns {{ok: true, to: string, by: string} | {ok: false, error: string}}
|
|
76
|
+
*/
|
|
77
|
+
export function acceptTicket(decoded, {siteSlug = process.env.SITE_SLUG, now = Date.now()} = {}) {
|
|
78
|
+
for (const [jti, exp] of used) if (exp < now) used.delete(jti);
|
|
79
|
+
if (!siteSlug) return {ok: false, error: 'This site is not run by a Domma CMS Manager.'};
|
|
80
|
+
if (decoded?.type !== 'staff-ticket' || decoded.dcmStaff !== true) return {ok: false, error: 'Not a staff ticket.'};
|
|
81
|
+
if (decoded.site !== siteSlug) return {ok: false, error: 'This ticket is for another site.'};
|
|
82
|
+
if (!STAFF_SCREENS.includes(decoded.to)) return {ok: false, error: 'This ticket does not open anything here.'};
|
|
83
|
+
if (typeof decoded.jti !== 'string' || decoded.jti.length < 16) return {ok: false, error: 'Not a staff ticket.'};
|
|
84
|
+
if (used.has(decoded.jti)) return {ok: false, error: 'This ticket has been used - open it again from the manager.'};
|
|
85
|
+
used.set(decoded.jti, (Number(decoded.exp) || 0) * 1000 + 60_000);
|
|
86
|
+
return {ok: true, to: decoded.to, by: String(decoded.by || 'staff').slice(0, 120)};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
90
|
+
const page = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title></head><body>${body}</body></html>`;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET /_staff/open?ticket=… - exchange a Manager's ticket for a staff session.
|
|
95
|
+
*
|
|
96
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
97
|
+
*/
|
|
98
|
+
export async function staffAccessRoutes(fastify) {
|
|
99
|
+
fastify.get('/_staff/open', {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
100
|
+
reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer').header('X-Robots-Tag', 'noindex');
|
|
101
|
+
const refuse = (error) => reply.code(403).type('text/html; charset=utf-8')
|
|
102
|
+
.send(page('Not opened', `<p>${error.replace(/[<>&]/g, '')}</p>`));
|
|
103
|
+
const ticket = typeof request.query?.ticket === 'string' ? request.query.ticket : '';
|
|
104
|
+
if (!ticket || ticket.length > 4096) return refuse('Not a staff ticket.');
|
|
105
|
+
let decoded;
|
|
106
|
+
try {
|
|
107
|
+
decoded = fastify.jwt.verify(ticket);
|
|
108
|
+
} catch {
|
|
109
|
+
return refuse('This ticket is not valid or has run out - open it again from the manager.');
|
|
110
|
+
}
|
|
111
|
+
const ok = acceptTicket(decoded);
|
|
112
|
+
if (!ok.ok) return refuse(ok.error);
|
|
113
|
+
|
|
114
|
+
const role = getRoleHierarchy()[0];
|
|
115
|
+
if (!role) return refuse('This site has no roles yet.');
|
|
116
|
+
const user = {id: STAFF_ID, name: `${ok.by} (staff)`, email: '', role, additionalRoles: [], level: 0, dcmStaff: true};
|
|
117
|
+
const token = fastify.jwt.sign({id: STAFF_ID, role, type: 'access', dcmStaff: true, site: decoded.site, name: user.name},
|
|
118
|
+
{expiresIn: STAFF_SESSION_SECONDS});
|
|
119
|
+
// warn, not info: production logs at warn, and this is the site's only record of a staff visit.
|
|
120
|
+
fastify.log.warn?.(`[staff] ${ok.by} opened a staff session at ${ok.to} (ticket ${String(decoded.jti).slice(0, 8)}…)`);
|
|
121
|
+
return reply.type('text/html; charset=utf-8').send(page('Opening…', `<p>Opening…</p>
|
|
122
|
+
<script src="/dist/domma/domma.min.js"></script>
|
|
123
|
+
<script>
|
|
124
|
+
S.remove('auth_refresh_token');
|
|
125
|
+
S.set('auth_token', ${json(token)});
|
|
126
|
+
S.set('auth_user', ${json(user)});
|
|
127
|
+
location.replace('/admin/' + ${json(ok.to)});
|
|
128
|
+
</script>`));
|
|
129
|
+
});
|
|
130
|
+
}
|
package/server/services/tools.js
CHANGED
|
@@ -39,6 +39,19 @@
|
|
|
39
39
|
* own admin cannot change that switch; only its Manager, which writes the
|
|
40
40
|
* files directly. A cascade that would move a locked switch is refused.
|
|
41
41
|
*
|
|
42
|
+
* ## Off for the site, on for the hosting staff (`staff`)
|
|
43
|
+
*
|
|
44
|
+
* { "wordpress-import": { "enabled": false, "locked": true, "staff": true } }
|
|
45
|
+
*
|
|
46
|
+
* `staff: true` on a Tool that is off keeps it open to a STAFF SESSION - one
|
|
47
|
+
* the site's Manager minted (services/staffAccess.js, `dcmStaff`) - and to
|
|
48
|
+
* nobody else, not even the site's own top role: its API answers a staff
|
|
49
|
+
* session (toolGate), its sidebar entry is shown to one only, and the admin
|
|
50
|
+
* route lets one in. Everyone else sees an ordinary switched-off Tool. The
|
|
51
|
+
* Manager writes it - hosted sites get the WordPress importer this way until
|
|
52
|
+
* it is gifted. It changes nothing the planner decides: a staff-only Tool is
|
|
53
|
+
* OFF to planSwitch().
|
|
54
|
+
*
|
|
42
55
|
* @module services/tools
|
|
43
56
|
*/
|
|
44
57
|
import {getConfig, saveConfig} from '../config.js';
|
|
@@ -84,6 +97,35 @@ export function isToolLocked(name) {
|
|
|
84
97
|
return getToolStates()[name]?.locked === true;
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether a Tool in this stored state is open to a viewer. Pure: on for
|
|
102
|
+
* everyone unless switched off; off with `staff: true` is still on for a
|
|
103
|
+
* staff session (services/staffAccess.js isDcmStaff) - and for nobody else.
|
|
104
|
+
*
|
|
105
|
+
* @param {{enabled?: boolean, staff?: boolean}|undefined} state
|
|
106
|
+
* @param {boolean} staffSession isDcmStaff(request.user)
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function toolOpenAt(state, staffSession) {
|
|
110
|
+
if (state?.enabled !== false) return true;
|
|
111
|
+
return state?.staff === true && staffSession === true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {string} name @returns {boolean} off for the site, kept open to staff sessions (`staff`) */
|
|
115
|
+
export function isToolStaffOnly(name) {
|
|
116
|
+
const s = getToolStates()[name];
|
|
117
|
+
return s?.enabled === false && s?.staff === true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @param {boolean} staffSession isDcmStaff(request.user)
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export function isToolOpenTo(name, staffSession) {
|
|
126
|
+
return toolOpenAt(getToolStates()[name], staffSession);
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
/**
|
|
88
130
|
* @param {string} name
|
|
89
131
|
* @param {{enabled?: boolean, locked?: boolean}} patch
|
|
@@ -121,7 +163,8 @@ export function switchableUnits({manifests = [], pluginStates = {}, toolStates =
|
|
|
121
163
|
for (const t of CORE_TOOLS) {
|
|
122
164
|
const s = toolStates[t.name] || {};
|
|
123
165
|
units.set(t.name, {name: t.name, kind: 'tool', displayName: t.displayName, icon: t.icon,
|
|
124
|
-
enabled: s.enabled !== false, locked: s.locked === true,
|
|
166
|
+
enabled: s.enabled !== false, locked: s.locked === true, staff: s.enabled === false && s.staff === true,
|
|
167
|
+
requires: [], uses: {}, supersedes: [], closedSource: false});
|
|
125
168
|
}
|
|
126
169
|
for (const m of manifests) {
|
|
127
170
|
if (!m?.name || units.has(m.name)) continue;
|