domma-cms 1.8.13 → 1.8.14

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.14] - 2026-10-16
4
+
5
+ - **Signed-only sites close every way of putting plugin code on the server.** With `requireSignedPlugins: true` in `config/server.json` (or `DOMMA_SIGNED_ONLY=1` in the environment, which Domma CMS Manager sets on the sites it hosts), unsigned plugin files are refused straight away with a plain message - no "install anyway?" question first - and New plugin and changes in a plugin's code editor are refused too: the Marketplace screen leaves New plugin off, and the code editor opens read only. Installing and updating from the Marketplace work as before. A licensed plugin's source stays closed on such a site, Super Admins included. MongoDB connection options that could start a program or read a file on the server (`autoEncryption`, `tls…File` and the like) are refused there as well. Self-hosted sites, where the setting is off, are unchanged.
6
+ - **Fixed: a crafted address could make the forms, views, menus, context menus, components and collections stores read or write a file outside their own folder.** An encoded `../` in the slug (`/api/forms/..%2F..%2Fconfig%2Fserver`) reached the file system, so an admin could rewrite other JSON settings through an ordinary save. Each store now accepts a plain name only.
7
+ - A plugin bundle is now refused when the manifest inside names a different plugin from the one it is being installed as (an update already checked this). `.env` is loaded without taking dotenv's options from the environment, so it can never override a variable the process was started with.
8
+
3
9
  ## [1.8.13] - 2026-10-16
4
10
 
5
11
  - **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.
package/CLAUDE.md CHANGED
@@ -175,6 +175,23 @@ upgrade extracts to `.<slug>.incoming` and validates it with `validatePluginDir(
175
175
  copy is touched, carries `data/` and `.entitlement.json` across, and restores the old directory on
176
176
  any failure - "neither version installed" is the one state it must never produce. Versions compare
177
177
  numerically (1.10.0 > 1.2.0). Unsigned installs and downgrades each need their own explicit flag.
178
+
179
+ **Signed-only sites (hosted) - `server/services/codePolicy.js`.** `signedOnly()` is true when the environment has
180
+ `DOMMA_SIGNED_ONLY=1` (the Manager sets it on sites it forks, after the site's .env), when server.json had
181
+ `requireSignedPlugins: true` at boot, or when it has it now. On such a site the hosted owner is level 0, and
182
+ code they run could read JWT_SECRET and forge a staff session, so the SERVICES refuse (403, `code:
183
+ 'signed-only'`): unsigned installs (`installPlugin`, checked before the "has to be confirmed" refusal),
184
+ `scaffoldPlugin`, `writePluginFile`/`deletePluginFile`; the routes also refuse an unsigned upload before the
185
+ 409 upgrade question, read licensed source as level 0, and save risky MongoDB options (`riskyConnectionOptions`;
186
+ `connectionManager` skips such a connection there). `GET /api/plugins/_policy` tells the screens (no New
187
+ plugin; the code editor is read only). Nothing may write server.json from the admin. A new door that lets the
188
+ admin put code or a file into plugins/ or server/ must call `assertMayChangeCode()`. `.env` loads through `server/env.js`
189
+ (`dotenv.config({override: false})`, never `dotenv/config`, which reads `DOTENV_CONFIG_OVERRIDE` from the environment).
190
+
191
+ **File stores take plain names only - `server/services/safePath.js` `fileIn(dir, name, ext)`.** Fastify decodes
192
+ route params, so `/api/forms/..%2F..%2Fconfig%2Fserver` arrived as `../../config/server` and the forms PUT
193
+ rewrote config/server.json. forms, views, menus, context menus, components, collections (+ FileAdapter) and
194
+ `getConfig`/`saveConfig` build their paths through `fileIn`; a new store keyed by a slug must too.
178
195
  The multipart consent flags are read **after** the file is drained, because a field the browser wrote
179
196
  after the file part is not on `upload.fields` before then.
180
197
 
package/admin/js/api.js CHANGED
@@ -1 +1 @@
1
- import{claimDrafts as $}from"./lib/draft-owner.js";import{loginHashFor as O}from"./lib/login-arrange.js";const m="/api";function a(){return S.get("auth_token")}function u(){return S.get("auth_refresh_token")}function y(e){S.set("auth_token",e)}function f(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function g(){const e=u();if(!e)throw new Error("No refresh token");const o=await fetch(`${m}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw f(),sendToLogin({expired:!0}),new Error("Token refresh failed");const{token:n}=await o.json();return y(n),n}async function t(e,o={}){let n=a();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let s=await fetch(`${m}${e}`,{...o,headers:r(n)});if(s.status===401&&u())try{n=await g(),s=await fetch(`${m}${e}`,{...o,headers:r(n)})}catch{return}if(!s.ok){const i=await s.json().catch(()=>({error:"Request failed"})),d=new Error(i.error||i.message||`HTTP ${s.status}`);throw Array.isArray(i.validation)&&(d.validation=i.validation),d.status=s.status,d.body=i,d}return s.status===204?null:s.json()}async function b(e,o){const n=a(),r=n?{Authorization:`Bearer ${n}`}:{},s=await fetch(`${m}${e}`,{method:"POST",headers:r,body:o});if(!s.ok){const i=await s.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${s.status}`)}return s.json()}export const api={auth:{setupStatus:()=>t("/auth/setup-status",{method:"GET"}),setup:e=>t("/auth/setup",{method:"POST",body:JSON.stringify(e)}),login:e=>t("/auth/login",{method:"POST",body:JSON.stringify(e)}),verifyLogin:e=>t("/auth/login/verify",{method:"POST",body:JSON.stringify(e)}),me:()=>t("/auth/me",{method:"GET"}),updateMe:e=>t("/auth/me",{method:"PUT",body:JSON.stringify(e)}),refresh:e=>t("/auth/refresh",{method:"POST",body:JSON.stringify({refreshToken:e})}),forgotPassword:e=>t("/auth/forgot-password",{method:"POST",body:JSON.stringify({email:e})}),checkReset:(e,o)=>t("/auth/reset-password/check",{method:"POST",body:JSON.stringify({token:e,...o&&{password:o}})}),resetPassword:(e,o)=>t("/auth/reset-password",{method:"POST",body:JSON.stringify({token:e,password:o})})},pages:{list:()=>t("/pages",{method:"GET"}),get:e=>t(`/pages${e}`,{method:"GET"}),create:e=>t("/pages",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/pages${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/pages${e}`,{method:"DELETE"}),preview:e=>t("/pages/preview",{method:"POST",body:JSON.stringify({markdown:e})}),previewFull:(e,o,n)=>t("/pages/preview/full",{method:"POST",body:JSON.stringify({urlPath:e,frontmatter:o,body:n})}),previewLinks:{list:e=>t(`/pages/preview-links${e?`?urlPath=${encodeURIComponent(e)}`:""}`,{method:"GET"}).then(o=>o.links||[]),create:(e,o,n)=>t("/pages/preview-links",{method:"POST",body:JSON.stringify({urlPath:e,expiresIn:o,label:n})}),revoke:e=>t(`/pages/preview-links/${e}`,{method:"DELETE"})},tags:()=>t("/pages/tags",{method:"GET"}).then(e=>e.tags||[])},listTags:{list:e=>t(`/list-tags/${e}`,{method:"GET"}).then(o=>o.tags||[]),add:(e,o)=>t(`/list-tags/${e}`,{method:"POST",body:JSON.stringify({name:o})}),changeItem:(e,o,n)=>t(`/list-tags/${e}/item`,{method:"PUT",body:JSON.stringify({id:o,...n})}).then(r=>r.tags||[]),rename:(e,o,n)=>t(`/list-tags/${e}/rename`,{method:"POST",body:JSON.stringify({from:o,to:n})}),remove:(e,o)=>t(`/list-tags/${e}/delete`,{method:"POST",body:JSON.stringify({name:o})})},settings:{get:()=>t("/settings",{method:"GET"}),save:e=>t("/settings",{method:"PUT",body:JSON.stringify(e)}),getCustomCss:()=>t("/settings/custom-css",{method:"GET"}),saveCustomCss:e=>t("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:e})})},theme:{get:()=>t("/theme",{method:"GET"}),save:e=>t("/theme",{method:"PUT",body:JSON.stringify(e)}),catalog:()=>t("/theme/catalog",{method:"GET"}),admin:e=>t(`/theme/admin${e?`?id=${encodeURIComponent(e)}`:""}`,{method:"GET"}),themeTokens:e=>t(`/theme/themes/${encodeURIComponent(e)}`,{method:"GET"}),compile:e=>t("/theme/compile",{method:"POST",body:JSON.stringify(e)})},navigation:{get:()=>t("/navigation",{method:"GET"}),save:e=>t("/navigation",{method:"PUT",body:JSON.stringify(e)})},menus:{list:()=>t("/menus",{method:"GET"}),get:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"})},contextMenus:{list:()=>t("/context-menus",{method:"GET"}),get:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/context-menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/context-menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"}),actionTypes:()=>t("/context-menus/action-types",{method:"GET"})},menuLocations:{get:()=>t("/menu-locations",{method:"GET"}),save:e=>t("/menu-locations",{method:"PUT",body:JSON.stringify(e)}),registry:()=>t("/menu-locations/registry",{method:"GET"})},projects:{list:()=>t("/projects",{method:"GET"}),listWithCounts:()=>t("/projects?counts=1",{method:"GET"}),get:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/projects",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/projects/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),artefacts:e=>t(`/projects/${encodeURIComponent(e)}/artefacts`,{method:"GET"}),untagAll:e=>t(`/projects/${encodeURIComponent(e)}/untag-all`,{method:"POST"}),removeContents:(e,o)=>t(`/projects/${encodeURIComponent(e)}/remove-contents`,{method:"POST",body:JSON.stringify({confirm:o})})},apiTokens:{list:()=>t("/api-tokens",{method:"GET"}),create:e=>t("/api-tokens",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),revoke:e=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"DELETE"})},apiEndpoints:{list:()=>t("/api-endpoints",{method:"GET"}),get:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/api-endpoints",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"DELETE"})},layouts:{get:()=>t("/layouts",{method:"GET"}),save:e=>t("/layouts",{method:"PUT",body:JSON.stringify(e)}),create:e=>t("/layouts",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/layouts/${e}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/layouts/${e}`,{method:"DELETE"}),getOptions:()=>t("/layouts/options",{method:"GET"}),saveOptions:e=>t("/layouts/options",{method:"PUT",body:JSON.stringify(e)})},media:{list:()=>t("/media",{method:"GET"}),upload:e=>b("/media",e),delete:e=>t(`/media/${encodeURIComponent(e)}`,{method:"DELETE"}),rename:(e,o)=>t(`/media/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify({newName:o})}),info:e=>t(`/media/${encodeURIComponent(e)}/info`,{method:"GET"}),transform:(e,o)=>t(`/media/${encodeURIComponent(e)}/transform`,{method:"POST",body:JSON.stringify(o)})},users:{list:()=>t("/users",{method:"GET"}),get:e=>t(`/users/${e}`,{method:"GET"}),create:e=>t("/users",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/users/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/users/${e}`,{method:"DELETE"}),passwordReset:(e,o)=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"POST",body:JSON.stringify({via:o})}),cancelPasswordReset:e=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"DELETE"})},tools:{list:()=>t("/tools",{method:"GET"}),update:(e,o)=>t(`/tools/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)})},plugins:{list:()=>t("/plugins",{method:"GET"}),scaffold:e=>t("/plugins/scaffold",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),files:e=>t(`/plugins/${e}/files`,{method:"GET"}),readFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"GET"}),writeFile:(e,o,n)=>t(`/plugins/${e}/file`,{method:"PUT",body:JSON.stringify({path:o,content:n})}),deleteFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"DELETE"}),restart:()=>t("/plugins/restart",{method:"POST"}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"}),async exportBundle(e,{includeData:o=!1}={}){const n=a(),r=o?"?includeData=1":"",s=await fetch(`${m}/plugins/${encodeURIComponent(e)}/export${r}`,{headers:n?{Authorization:`Bearer ${n}`}:{}});if(!s.ok){const E=await s.json().catch(()=>({}));throw new Error(E.error||`Export failed (${s.status})`)}const i=s.headers.get("Content-Disposition")||"",d=/filename="([^"]+)"/.exec(i)?.[1],c=await s.blob(),h=URL.createObjectURL(c),l=document.createElement("a");l.href=h,l.download=d||`${e}.dcmsplugin`,document.body.appendChild(l),l.click(),l.remove(),URL.revokeObjectURL(h)},async installFile(e,{confirmUnsigned:o=!1,confirmUpgrade:n=!1,confirmDowngrade:r=!1}={}){const s=new FormData;o&&s.append("confirmUnsigned","true"),n&&s.append("confirmUpgrade","true"),r&&s.append("confirmDowngrade","true"),s.append("file",e);const i=a(),d=await fetch(`${m}/plugins/install-upload`,{method:"POST",headers:i?{Authorization:`Bearer ${i}`}:{},body:s}),c=await d.json().catch(()=>({}));if(d.status===409){const h=new Error(c.error||"Plugin already installed");throw h.code="CONFLICT",h.name=c.name,h.installedVersion=c.installedVersion,h.incomingVersion=c.incomingVersion,h}if(!d.ok)throw new Error(c.error||`Install failed (${d.status})`);return c}},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o,{update:n=!1}={})=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o,update:n})}),uninstall:e=>t(`/plugins/marketplace/${encodeURIComponent(e)}`,{method:"DELETE"})},collections:{list:()=>t("/collections",{method:"GET"}),proStatus:()=>t("/collections/pro-status",{method:"GET"}),get:e=>t(`/collections/${e}`,{method:"GET"}),create:e=>t("/collections",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/collections/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/collections/${e}`,{method:"DELETE"}),listEntries:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${n?"?"+n:""}`,{method:"GET"})},getEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"GET"}),createEntry:(e,o)=>t(`/collections/${e}/entries`,{method:"POST",body:JSON.stringify({data:o})}),updateEntry:(e,o,n)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:n})}),deleteEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"DELETE"}),clearEntries:e=>t(`/collections/${e}/entries`,{method:"DELETE"}),import:(e,o)=>t(`/collections/${e}/import`,{method:"POST",body:JSON.stringify({entries:o})}),publicList:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/public${n?"?"+n:""}`,{method:"GET"})},getConnections:()=>t("/collections/connections",{method:"GET"}),saveConnections:e=>t("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),migrateStorage:(e,o)=>t(`/collections/${e}/migrate-storage`,{method:"POST",body:JSON.stringify({storage:o})})},forms:{list:()=>t("/forms",{method:"GET"}),create:e=>t("/forms",{method:"POST",body:JSON.stringify(e)}),get:e=>t(`/forms/${e}`,{method:"GET"}),update:(e,o)=>t(`/forms/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/forms/${e}`,{method:"DELETE"}),listSubmissions:e=>t(`/forms/${e}/submissions`,{method:"GET"}),clearSubmissions:e=>t(`/forms/${e}/submissions`,{method:"DELETE"}),deleteSubmission:(e,o)=>t(`/forms/${e}/submissions/${o}`,{method:"DELETE"}),testEmail:e=>t("/forms/test-email",{method:"POST",body:JSON.stringify({to:e})})},views:{list:()=>t("/views",{method:"GET"}),get:e=>t(`/views/${e}`,{method:"GET"}),create:e=>t("/views",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/views/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/views/${e}`,{method:"DELETE"}),execute:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/views/${e}/execute${n?"?"+n:""}`,{method:"GET"})},forCollection:e=>t(`/views/collection/${e}`,{method:"GET"})},actions:{list:()=>t("/actions",{method:"GET"}),get:e=>t(`/actions/${e}`,{method:"GET"}),create:e=>t("/actions",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/actions/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/actions/${e}`,{method:"DELETE"}),execute:(e,o)=>t(`/actions/${e}/execute`,{method:"POST",body:JSON.stringify({entryId:o})}),forCollection:e=>t(`/actions/collection/${e}`,{method:"GET"}),checkAccess:(e,o)=>t(`/actions/${e}/check-access`,{method:"POST",body:JSON.stringify({entryIds:o})})},versions:{list:e=>t(`/versions/list${e}`),get:(e,o)=>t(`/versions/get/${encodeURIComponent(o)}${e}`),create:(e,o)=>t(`/versions/create${e}`,{method:"POST",body:JSON.stringify({label:o})}),restore:(e,o)=>t(`/versions/restore/${encodeURIComponent(o)}${e}`,{method:"POST"}),delete:(e,o)=>t(`/versions/delete/${encodeURIComponent(o)}${e}`,{method:"DELETE"}),bulkDelete:(e,o)=>t(`/versions/bulk-delete${e}`,{method:"POST",body:JSON.stringify({filenames:o})}),prune:(e,o)=>t(`/versions/prune${e}`,{method:"POST",body:JSON.stringify({keep:o})})},blocks:{list:()=>t("/blocks",{method:"GET"}),get:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"GET"}),put:(e,o)=>t(`/blocks/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${m}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${m}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Block already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},components:{list:()=>t("/components",{method:"GET"}),get:e=>t(`/components/${encodeURIComponent(e)}`,{method:"GET"}),compile:(e,o)=>t("/components/compile",{method:"POST",body:JSON.stringify({name:e,source:o})}),put:(e,o)=>t(`/components/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/components/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${m}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${m}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Component already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},get:e=>t(e,{method:"GET"}),post:(e,o)=>t(e,{method:"POST",body:JSON.stringify(o)}),put:(e,o)=>t(e,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(e,{method:"DELETE"}),settingsExt:{testEmail:e=>t("/settings/test-email",{method:"POST",body:JSON.stringify({to:e})})},system:{notifications:{list:()=>t("/system/notifications",{method:"GET"}),unreadCount:()=>t("/system/notifications/unread-count",{method:"GET"}),markRead:e=>t(`/system/notifications/${e}/read`,{method:"POST"}),dismiss:e=>t(`/system/notifications/${e}/dismiss`,{method:"POST"}),remove:e=>t(`/system/notifications/${e}`,{method:"DELETE"})}},dashboard:{summary:()=>t("/dashboard/summary",{method:"GET"}),summaryLite:()=>t("/dashboard/summary?lite=1",{method:"GET"})}};export function isAuthenticated(){return!!a()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:n}){e&&y(e),o&&S.set("auth_refresh_token",o),n&&($(n.id),S.set("auth_user",n))}let p=!1,T=!1;export function sendToLogin({expired:e=!1}={}){/^#\/(login|reset-password)(\?|\/|$)/.test(location.hash)||(location.hash=O(location.hash,{expired:e}))}export function logout(){!T&&window.M?.subscribe&&(T=!0,M.subscribe("router:afterChange",({to:e}={})=>{/^\/login(\?|$)/.test(e?.path||"")||(p=!1)})),p=!0,R.navigate("/login")}export function takePendingLogout(){return p?(p=!1,P(),!0):!1}function P(){const e=u();e&&fetch(`${m}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),f()}export{t as apiRequest,g as refreshAccessToken,a as getToken,f as clearAuth};
1
+ import{claimDrafts as $}from"./lib/draft-owner.js";import{loginHashFor as O}from"./lib/login-arrange.js";const m="/api";function a(){return S.get("auth_token")}function u(){return S.get("auth_refresh_token")}function f(e){S.set("auth_token",e)}function y(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function g(){const e=u();if(!e)throw new Error("No refresh token");const o=await fetch(`${m}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw y(),sendToLogin({expired:!0}),new Error("Token refresh failed");const{token:n}=await o.json();return f(n),n}async function t(e,o={}){let n=a();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let s=await fetch(`${m}${e}`,{...o,headers:r(n)});if(s.status===401&&u())try{n=await g(),s=await fetch(`${m}${e}`,{...o,headers:r(n)})}catch{return}if(!s.ok){const i=await s.json().catch(()=>({error:"Request failed"})),d=new Error(i.error||i.message||`HTTP ${s.status}`);throw Array.isArray(i.validation)&&(d.validation=i.validation),d.status=s.status,d.body=i,d}return s.status===204?null:s.json()}async function b(e,o){const n=a(),r=n?{Authorization:`Bearer ${n}`}:{},s=await fetch(`${m}${e}`,{method:"POST",headers:r,body:o});if(!s.ok){const i=await s.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${s.status}`)}return s.json()}export const api={auth:{setupStatus:()=>t("/auth/setup-status",{method:"GET"}),setup:e=>t("/auth/setup",{method:"POST",body:JSON.stringify(e)}),login:e=>t("/auth/login",{method:"POST",body:JSON.stringify(e)}),verifyLogin:e=>t("/auth/login/verify",{method:"POST",body:JSON.stringify(e)}),me:()=>t("/auth/me",{method:"GET"}),updateMe:e=>t("/auth/me",{method:"PUT",body:JSON.stringify(e)}),refresh:e=>t("/auth/refresh",{method:"POST",body:JSON.stringify({refreshToken:e})}),forgotPassword:e=>t("/auth/forgot-password",{method:"POST",body:JSON.stringify({email:e})}),checkReset:(e,o)=>t("/auth/reset-password/check",{method:"POST",body:JSON.stringify({token:e,...o&&{password:o}})}),resetPassword:(e,o)=>t("/auth/reset-password",{method:"POST",body:JSON.stringify({token:e,password:o})})},pages:{list:()=>t("/pages",{method:"GET"}),get:e=>t(`/pages${e}`,{method:"GET"}),create:e=>t("/pages",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/pages${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/pages${e}`,{method:"DELETE"}),preview:e=>t("/pages/preview",{method:"POST",body:JSON.stringify({markdown:e})}),previewFull:(e,o,n)=>t("/pages/preview/full",{method:"POST",body:JSON.stringify({urlPath:e,frontmatter:o,body:n})}),previewLinks:{list:e=>t(`/pages/preview-links${e?`?urlPath=${encodeURIComponent(e)}`:""}`,{method:"GET"}).then(o=>o.links||[]),create:(e,o,n)=>t("/pages/preview-links",{method:"POST",body:JSON.stringify({urlPath:e,expiresIn:o,label:n})}),revoke:e=>t(`/pages/preview-links/${e}`,{method:"DELETE"})},tags:()=>t("/pages/tags",{method:"GET"}).then(e=>e.tags||[])},listTags:{list:e=>t(`/list-tags/${e}`,{method:"GET"}).then(o=>o.tags||[]),add:(e,o)=>t(`/list-tags/${e}`,{method:"POST",body:JSON.stringify({name:o})}),changeItem:(e,o,n)=>t(`/list-tags/${e}/item`,{method:"PUT",body:JSON.stringify({id:o,...n})}).then(r=>r.tags||[]),rename:(e,o,n)=>t(`/list-tags/${e}/rename`,{method:"POST",body:JSON.stringify({from:o,to:n})}),remove:(e,o)=>t(`/list-tags/${e}/delete`,{method:"POST",body:JSON.stringify({name:o})})},settings:{get:()=>t("/settings",{method:"GET"}),save:e=>t("/settings",{method:"PUT",body:JSON.stringify(e)}),getCustomCss:()=>t("/settings/custom-css",{method:"GET"}),saveCustomCss:e=>t("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:e})})},theme:{get:()=>t("/theme",{method:"GET"}),save:e=>t("/theme",{method:"PUT",body:JSON.stringify(e)}),catalog:()=>t("/theme/catalog",{method:"GET"}),admin:e=>t(`/theme/admin${e?`?id=${encodeURIComponent(e)}`:""}`,{method:"GET"}),themeTokens:e=>t(`/theme/themes/${encodeURIComponent(e)}`,{method:"GET"}),compile:e=>t("/theme/compile",{method:"POST",body:JSON.stringify(e)})},navigation:{get:()=>t("/navigation",{method:"GET"}),save:e=>t("/navigation",{method:"PUT",body:JSON.stringify(e)})},menus:{list:()=>t("/menus",{method:"GET"}),get:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"})},contextMenus:{list:()=>t("/context-menus",{method:"GET"}),get:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/context-menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/context-menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"}),actionTypes:()=>t("/context-menus/action-types",{method:"GET"})},menuLocations:{get:()=>t("/menu-locations",{method:"GET"}),save:e=>t("/menu-locations",{method:"PUT",body:JSON.stringify(e)}),registry:()=>t("/menu-locations/registry",{method:"GET"})},projects:{list:()=>t("/projects",{method:"GET"}),listWithCounts:()=>t("/projects?counts=1",{method:"GET"}),get:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/projects",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/projects/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),artefacts:e=>t(`/projects/${encodeURIComponent(e)}/artefacts`,{method:"GET"}),untagAll:e=>t(`/projects/${encodeURIComponent(e)}/untag-all`,{method:"POST"}),removeContents:(e,o)=>t(`/projects/${encodeURIComponent(e)}/remove-contents`,{method:"POST",body:JSON.stringify({confirm:o})})},apiTokens:{list:()=>t("/api-tokens",{method:"GET"}),create:e=>t("/api-tokens",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),revoke:e=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"DELETE"})},apiEndpoints:{list:()=>t("/api-endpoints",{method:"GET"}),get:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/api-endpoints",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"DELETE"})},layouts:{get:()=>t("/layouts",{method:"GET"}),save:e=>t("/layouts",{method:"PUT",body:JSON.stringify(e)}),create:e=>t("/layouts",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/layouts/${e}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/layouts/${e}`,{method:"DELETE"}),getOptions:()=>t("/layouts/options",{method:"GET"}),saveOptions:e=>t("/layouts/options",{method:"PUT",body:JSON.stringify(e)})},media:{list:()=>t("/media",{method:"GET"}),upload:e=>b("/media",e),delete:e=>t(`/media/${encodeURIComponent(e)}`,{method:"DELETE"}),rename:(e,o)=>t(`/media/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify({newName:o})}),info:e=>t(`/media/${encodeURIComponent(e)}/info`,{method:"GET"}),transform:(e,o)=>t(`/media/${encodeURIComponent(e)}/transform`,{method:"POST",body:JSON.stringify(o)})},users:{list:()=>t("/users",{method:"GET"}),get:e=>t(`/users/${e}`,{method:"GET"}),create:e=>t("/users",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/users/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/users/${e}`,{method:"DELETE"}),passwordReset:(e,o)=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"POST",body:JSON.stringify({via:o})}),cancelPasswordReset:e=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"DELETE"})},tools:{list:()=>t("/tools",{method:"GET"}),update:(e,o)=>t(`/tools/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)})},plugins:{list:()=>t("/plugins",{method:"GET"}),scaffold:e=>t("/plugins/scaffold",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),files:e=>t(`/plugins/${e}/files`,{method:"GET"}),readFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"GET"}),writeFile:(e,o,n)=>t(`/plugins/${e}/file`,{method:"PUT",body:JSON.stringify({path:o,content:n})}),deleteFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"DELETE"}),restart:()=>t("/plugins/restart",{method:"POST"}),policy:()=>t("/plugins/_policy",{method:"GET"}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"}),async exportBundle(e,{includeData:o=!1}={}){const n=a(),r=o?"?includeData=1":"",s=await fetch(`${m}/plugins/${encodeURIComponent(e)}/export${r}`,{headers:n?{Authorization:`Bearer ${n}`}:{}});if(!s.ok){const E=await s.json().catch(()=>({}));throw new Error(E.error||`Export failed (${s.status})`)}const i=s.headers.get("Content-Disposition")||"",d=/filename="([^"]+)"/.exec(i)?.[1],c=await s.blob(),h=URL.createObjectURL(c),l=document.createElement("a");l.href=h,l.download=d||`${e}.dcmsplugin`,document.body.appendChild(l),l.click(),l.remove(),URL.revokeObjectURL(h)},async installFile(e,{confirmUnsigned:o=!1,confirmUpgrade:n=!1,confirmDowngrade:r=!1}={}){const s=new FormData;o&&s.append("confirmUnsigned","true"),n&&s.append("confirmUpgrade","true"),r&&s.append("confirmDowngrade","true"),s.append("file",e);const i=a(),d=await fetch(`${m}/plugins/install-upload`,{method:"POST",headers:i?{Authorization:`Bearer ${i}`}:{},body:s}),c=await d.json().catch(()=>({}));if(d.status===409){const h=new Error(c.error||"Plugin already installed");throw h.code="CONFLICT",h.name=c.name,h.installedVersion=c.installedVersion,h.incomingVersion=c.incomingVersion,h}if(!d.ok)throw new Error(c.error||`Install failed (${d.status})`);return c}},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o,{update:n=!1}={})=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o,update:n})}),uninstall:e=>t(`/plugins/marketplace/${encodeURIComponent(e)}`,{method:"DELETE"})},collections:{list:()=>t("/collections",{method:"GET"}),proStatus:()=>t("/collections/pro-status",{method:"GET"}),get:e=>t(`/collections/${e}`,{method:"GET"}),create:e=>t("/collections",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/collections/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/collections/${e}`,{method:"DELETE"}),listEntries:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${n?"?"+n:""}`,{method:"GET"})},getEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"GET"}),createEntry:(e,o)=>t(`/collections/${e}/entries`,{method:"POST",body:JSON.stringify({data:o})}),updateEntry:(e,o,n)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:n})}),deleteEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"DELETE"}),clearEntries:e=>t(`/collections/${e}/entries`,{method:"DELETE"}),import:(e,o)=>t(`/collections/${e}/import`,{method:"POST",body:JSON.stringify({entries:o})}),publicList:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/public${n?"?"+n:""}`,{method:"GET"})},getConnections:()=>t("/collections/connections",{method:"GET"}),saveConnections:e=>t("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),migrateStorage:(e,o)=>t(`/collections/${e}/migrate-storage`,{method:"POST",body:JSON.stringify({storage:o})})},forms:{list:()=>t("/forms",{method:"GET"}),create:e=>t("/forms",{method:"POST",body:JSON.stringify(e)}),get:e=>t(`/forms/${e}`,{method:"GET"}),update:(e,o)=>t(`/forms/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/forms/${e}`,{method:"DELETE"}),listSubmissions:e=>t(`/forms/${e}/submissions`,{method:"GET"}),clearSubmissions:e=>t(`/forms/${e}/submissions`,{method:"DELETE"}),deleteSubmission:(e,o)=>t(`/forms/${e}/submissions/${o}`,{method:"DELETE"}),testEmail:e=>t("/forms/test-email",{method:"POST",body:JSON.stringify({to:e})})},views:{list:()=>t("/views",{method:"GET"}),get:e=>t(`/views/${e}`,{method:"GET"}),create:e=>t("/views",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/views/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/views/${e}`,{method:"DELETE"}),execute:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/views/${e}/execute${n?"?"+n:""}`,{method:"GET"})},forCollection:e=>t(`/views/collection/${e}`,{method:"GET"})},actions:{list:()=>t("/actions",{method:"GET"}),get:e=>t(`/actions/${e}`,{method:"GET"}),create:e=>t("/actions",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/actions/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/actions/${e}`,{method:"DELETE"}),execute:(e,o)=>t(`/actions/${e}/execute`,{method:"POST",body:JSON.stringify({entryId:o})}),forCollection:e=>t(`/actions/collection/${e}`,{method:"GET"}),checkAccess:(e,o)=>t(`/actions/${e}/check-access`,{method:"POST",body:JSON.stringify({entryIds:o})})},versions:{list:e=>t(`/versions/list${e}`),get:(e,o)=>t(`/versions/get/${encodeURIComponent(o)}${e}`),create:(e,o)=>t(`/versions/create${e}`,{method:"POST",body:JSON.stringify({label:o})}),restore:(e,o)=>t(`/versions/restore/${encodeURIComponent(o)}${e}`,{method:"POST"}),delete:(e,o)=>t(`/versions/delete/${encodeURIComponent(o)}${e}`,{method:"DELETE"}),bulkDelete:(e,o)=>t(`/versions/bulk-delete${e}`,{method:"POST",body:JSON.stringify({filenames:o})}),prune:(e,o)=>t(`/versions/prune${e}`,{method:"POST",body:JSON.stringify({keep:o})})},blocks:{list:()=>t("/blocks",{method:"GET"}),get:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"GET"}),put:(e,o)=>t(`/blocks/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${m}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${m}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Block already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},components:{list:()=>t("/components",{method:"GET"}),get:e=>t(`/components/${encodeURIComponent(e)}`,{method:"GET"}),compile:(e,o)=>t("/components/compile",{method:"POST",body:JSON.stringify({name:e,source:o})}),put:(e,o)=>t(`/components/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/components/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${m}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${m}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Component already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},get:e=>t(e,{method:"GET"}),post:(e,o)=>t(e,{method:"POST",body:JSON.stringify(o)}),put:(e,o)=>t(e,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(e,{method:"DELETE"}),settingsExt:{testEmail:e=>t("/settings/test-email",{method:"POST",body:JSON.stringify({to:e})})},system:{notifications:{list:()=>t("/system/notifications",{method:"GET"}),unreadCount:()=>t("/system/notifications/unread-count",{method:"GET"}),markRead:e=>t(`/system/notifications/${e}/read`,{method:"POST"}),dismiss:e=>t(`/system/notifications/${e}/dismiss`,{method:"POST"}),remove:e=>t(`/system/notifications/${e}`,{method:"DELETE"})}},dashboard:{summary:()=>t("/dashboard/summary",{method:"GET"}),summaryLite:()=>t("/dashboard/summary?lite=1",{method:"GET"})}};export function isAuthenticated(){return!!a()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:n}){e&&f(e),o&&S.set("auth_refresh_token",o),n&&($(n.id),S.set("auth_user",n))}let p=!1,T=!1;export function sendToLogin({expired:e=!1}={}){/^#\/(login|reset-password)(\?|\/|$)/.test(location.hash)||(location.hash=O(location.hash,{expired:e}))}export function logout(){!T&&window.M?.subscribe&&(T=!0,M.subscribe("router:afterChange",({to:e}={})=>{/^\/login(\?|$)/.test(e?.path||"")||(p=!1)})),p=!0,R.navigate("/login")}export function takePendingLogout(){return p?(p=!1,P(),!0):!1}function P(){const e=u();e&&fetch(`${m}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),y()}export{t as apiRequest,g as refreshAccessToken,a as getToken,y as clearAuth};
@@ -1,4 +1,4 @@
1
- import{api as u}from"../api.js";import{reportRestart as ze}from"../lib/server-restart.js";import{createSimpleEditor as He}from"../lib/simple-editor.js";import{languageForFile as Ee}from"../lib/syntax-highlight.js";import{addBannerAction as K}from"../lib/plugin-chrome.js";import{helpPopovers as Se}from"../lib/help-popover.js";import{onceClosed as Ue,openMenuAt as Z}from"../lib/tool-kit.js";import{REQUIRED_FILES as ee,checkPath as Ve,draftOf as Ye,fileRows as We,fileScope as te,restorePlan as _e}from"../lib/plugin-code-arrange.js";const Qe=/^\/plugins\/code\/[a-z0-9-]+\/?(\?.*)?$/,L=r=>`plugin-code.draft.${r}`,Ge=`
1
+ import{api as c}from"../api.js";import{reportRestart as He}from"../lib/server-restart.js";import{createSimpleEditor as Ue}from"../lib/simple-editor.js";import{languageForFile as Se}from"../lib/syntax-highlight.js";import{addBannerAction as K}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{onceClosed as Ve,openMenuAt as Z}from"../lib/tool-kit.js";import{REQUIRED_FILES as ee,checkPath as Ye,draftOf as We,fileRows as _e,fileScope as te,restorePlan as Qe}from"../lib/plugin-code-arrange.js";const Ge=/^\/plugins\/code\/[a-z0-9-]+\/?(\?.*)?$/,L=i=>`plugin-code.draft.${i}`,Je=`
2
2
  <div class="pcx-item" data-key="{{key}}">
3
3
  <div class="pcx-dir" data-bind-hidden="!isDir">{{label}}</div>
4
4
  <div class="pcx-file" data-bind-hidden="isDir" data-bind-class="cls" data-path="{{path}}" tabindex="0" title="{{path}} - {{meta}}">
@@ -6,7 +6,7 @@ import{api as u}from"../api.js";import{reportRestart as ze}from"../lib/server-re
6
6
  <span class="pcx-dirty" data-bind-hidden="!dirty" title="Unsaved changes">\u25CF</span>
7
7
  <button type="button" class="pcx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
8
8
  </div>
9
- </div>`,T=r=>String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Je(r){const d=document.createElement("template");return d.innerHTML=r.trim(),d.content.firstElementChild}function $e(r,d){const s=r.querySelector(".dm-plugin-banner-name");if(!s)return;const p=document.createElement("span");p.className="dm-plugin-banner-parent",p.textContent="Marketplace";const v=document.createElement("span");v.className="dm-plugin-banner-sep",v.setAttribute("aria-hidden","true"),v.textContent="\u203A",s.replaceChildren(p,v,document.createTextNode(d))}let f=null,Me=!1;function Xe(){Me||!window.R?.use||(Me=!0,R.use((r,d,s)=>{const v=Qe.test(d?.path||"")&&(r?.path||"")!==(d?.path||"")&&f?f.dirtyCount():0;return v?E.confirm(`${v===1?"A file has":`${v} files have`} unsaved changes. Leave anyway? They are kept as a draft for your next visit to this plugin's code.`).then(c=>{c?s():f&&history.replaceState(null,"",f.hash)}):s()}),M.subscribe("router:afterChange",({to:r}={})=>{if(f&&(r?.path||"")!==f.path){const d=f;f=null,d.dispose()}}))}export const pluginCodeView={templateUrl:"/admin/js/templates/plugin-code.html",async onMount(r){if(Xe(),f){const e=f;f=null,e.dispose()}const d=window.location.hash.match(/\/plugins\/code\/([a-z0-9-]+)/),s=d?d[1]:null;if(!s){E.toast("No plugin specified.",{type:"error"}),R.navigate("/plugins");return}const p=r.get(0).querySelector(".pcx")||r.get(0),v=[],c=e=>(v.push(e),e);let z=!1;const m=()=>!z&&p.isConnected,b=M.observable([]),ne=M.observable(!0),H=M.observable(""),ae=M.observable(""),o=M.observable(null),U=M.observable(0),w=M.observable(!1),A=M.observable("Ctrl+S saves the open file, Ctrl+Shift+S every unsaved file."),j=M.observable(""),V=M.observable(!1),i=new Map,k=()=>{U.value=U.peek()+1},F=e=>i.has(e)&&i.get(e).content!==i.get(e).saved,N=()=>[...i.keys()].filter(F),q=M.computed(()=>(U.value,new Set(N()))),se=M.observable([]);c(M.effect(()=>{se.value=We(b.value,{openPath:o.value,dirty:q.value,query:ae.value})}));const Ce={files:b,loading:ne,loadError:H,query:ae,openPath:o,saving:w,status:A,cursor:j,restartPending:V,rows:se,currentDirty:M.computed(()=>!!o.value&&q.value.has(o.value)),dirtyCount:M.computed(()=>q.value.size),dirtyText:M.computed(()=>q.value.size===1?"1 unsaved file":`${q.value.size} unsaved files`),currentText:M.computed(()=>{const e=o.value;return e?e+(Ee(e)?"":" (plain text)"):"Select a file"}),scopeLabel:M.computed(()=>o.value?te(o.value).label:""),scopeHint:M.computed(()=>o.value?te(o.value).hint:""),scopeCls:M.computed(()=>o.value&&te(o.value).kind==="server"?"is-warn":"is-info"),emptyText:M.computed(()=>b.value.length?"No files match.":"This plugin has no editable files.")};async function x(){try{const e=await u.plugins.files(s);if(!m())return;b.value=e.files||[],H.value=""}catch(e){H.value=`Could not list the files: ${e.message||e}`}finally{ne.value=!1}}const Y=p.querySelector("#pcode-editor");let h=null;c(()=>{try{h?.destroy()}catch{}h=null});function Re(e,t){try{h?.destroy()}catch{}Y.replaceChildren(),h=He(Y,{initialValue:t,language:Ee(e),autoIndent:!0,onChange:n=>{const a=i.get(e);if(!a)return;const l=a.content!==a.saved;a.content=n,a.content!==a.saved!==l&&k()},onCursor:({line:n,col:a})=>{j.value=`Ln ${n}, Col ${a}`}}),h.focus()}async function g(e){if(!(!e||e===o.peek())){if(!i.has(e))try{const t=await u.plugins.readFile(s,e);i.set(e,{content:t.content,saved:t.content})}catch(t){E.toast(`Could not open ${e}: ${t.message||t}`,{type:"error"});return}m()&&(o.value=e,j.value="Ln 1, Col 1",Re(e,i.get(e).content),k())}}async function oe(e){const t=i.get(e),n=t.content,a=await u.plugins.writeFile(s,e,n);return t.saved=n,!!a?.restartRequired}function re(e,t){e&&(V.value=!0),A.value=e?`Saved ${t} - server code: restart the server to apply.`:`Saved ${t} - refresh the admin tab to see it.`,k(),x()}async function W(e=o.peek()){if(!(!e||!F(e)||w.peek())){w.value=!0;try{const t=await oe(e);E.toast(t?"Saved. Restart the server to apply.":"Saved.",{type:t?"warning":"success"}),re(t,e)}catch(t){E.toast(`Not saved: ${t.message||t}`,{type:"error"})}finally{w.value=!1}}}async function ie(){const e=N();if(!e.length||w.peek())return;w.value=!0;let t=!1;const n=[];for(const a of e)try{t=await oe(a)||t}catch(l){n.push(`${a}: ${l.message||l}`)}w.value=!1,n.length?E.toast(`Some files were not saved - ${n.join("; ")}`,{type:"error",duration:8e3}):E.toast(`Saved ${e.length===1?"1 file":`${e.length} files`}.`,{type:t?"warning":"success"}),re(t,e.length===1?e[0]:`${e.length-n.length} files`)}async function le(e=o.peek()){if(!e||!F(e)||!await E.confirm(`Throw away your changes to ${T(e)}? It goes back to what is saved.`))return;const t=i.get(e);t.content=t.saved,e===o.peek()&&h?.setValue(t.saved),k()}const _=new Set;c(()=>[..._].forEach(e=>{try{e.close(!0)}catch{}}));function Q({title:e,initial:t="",from:n="",submitText:a,help:l}){return new Promise(Pe=>{const P=M.observable(t),ge=M.computed(()=>Ve(P.value,{existing:b.value.map(D=>D.path),from:n})),Ae=M.computed(()=>n&&P.value.trim()===n),be={value:P,err:ge,cannot:M.computed(()=>!!ge.value||Ae.value),submitText:a,help:l},y=E.slideover({title:T(e),size:"sm",position:"right"}),$=Je(`
9
+ </div>`,T=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Xe(i){const d=document.createElement("template");return d.innerHTML=i.trim(),d.content.firstElementChild}function Me(i,d){const s=i.querySelector(".dm-plugin-banner-name");if(!s)return;const p=document.createElement("span");p.className="dm-plugin-banner-parent",p.textContent="Marketplace";const v=document.createElement("span");v.className="dm-plugin-banner-sep",v.setAttribute("aria-hidden","true"),v.textContent="\u203A",s.replaceChildren(p,v,document.createTextNode(d))}let f=null,Ce=!1;function Ze(){Ce||!window.R?.use||(Ce=!0,R.use((i,d,s)=>{const v=Ge.test(d?.path||"")&&(i?.path||"")!==(d?.path||"")&&f?f.dirtyCount():0;return v?E.confirm(`${v===1?"A file has":`${v} files have`} unsaved changes. Leave anyway? They are kept as a draft for your next visit to this plugin's code.`).then(u=>{u?s():f&&history.replaceState(null,"",f.hash)}):s()}),M.subscribe("router:afterChange",({to:i}={})=>{if(f&&(i?.path||"")!==f.path){const d=f;f=null,d.dispose()}}))}export const pluginCodeView={templateUrl:"/admin/js/templates/plugin-code.html",async onMount(i){if(Ze(),f){const e=f;f=null,e.dispose()}const d=window.location.hash.match(/\/plugins\/code\/([a-z0-9-]+)/),s=d?d[1]:null;if(!s){E.toast("No plugin specified.",{type:"error"}),R.navigate("/plugins");return}const p=i.get(0).querySelector(".pcx")||i.get(0),v=[],u=e=>(v.push(e),e);let z=!1;const m=()=>!z&&p.isConnected,b=M.observable([]),ne=M.observable(!0),H=M.observable(""),ae=M.observable(""),o=M.observable(null),U=M.observable(0),w=M.observable(!1),F=M.observable("Ctrl+S saves the open file, Ctrl+Shift+S every unsaved file."),j=M.observable(""),V=M.observable(!1),r=new Map,k=()=>{U.value=U.peek()+1},O=e=>r.has(e)&&r.get(e).content!==r.get(e).saved,N=()=>[...r.keys()].filter(O),q=M.computed(()=>(U.value,new Set(N()))),se=M.observable([]);u(M.effect(()=>{se.value=_e(b.value,{openPath:o.value,dirty:q.value,query:ae.value})}));const Re={files:b,loading:ne,loadError:H,query:ae,openPath:o,saving:w,status:F,cursor:j,restartPending:V,rows:se,currentDirty:M.computed(()=>!!o.value&&q.value.has(o.value)),dirtyCount:M.computed(()=>q.value.size),dirtyText:M.computed(()=>q.value.size===1?"1 unsaved file":`${q.value.size} unsaved files`),currentText:M.computed(()=>{const e=o.value;return e?e+(Se(e)?"":" (plain text)"):"Select a file"}),scopeLabel:M.computed(()=>o.value?te(o.value).label:""),scopeHint:M.computed(()=>o.value?te(o.value).hint:""),scopeCls:M.computed(()=>o.value&&te(o.value).kind==="server"?"is-warn":"is-info"),emptyText:M.computed(()=>b.value.length?"No files match.":"This plugin has no editable files.")};async function x(){try{const e=await c.plugins.files(s);if(!m())return;b.value=e.files||[],H.value=""}catch(e){H.value=`Could not list the files: ${e.message||e}`}finally{ne.value=!1}}const Y=p.querySelector("#pcode-editor");let h=null;u(()=>{try{h?.destroy()}catch{}h=null});function De(e,t){try{h?.destroy()}catch{}Y.replaceChildren(),h=Ue(Y,{initialValue:t,language:Se(e),autoIndent:!0,onChange:n=>{const a=r.get(e);if(!a)return;const l=a.content!==a.saved;a.content=n,a.content!==a.saved!==l&&k()},onCursor:({line:n,col:a})=>{j.value=`Ln ${n}, Col ${a}`}}),h.focus()}async function g(e){if(!(!e||e===o.peek())){if(!r.has(e))try{const t=await c.plugins.readFile(s,e);r.set(e,{content:t.content,saved:t.content})}catch(t){E.toast(`Could not open ${e}: ${t.message||t}`,{type:"error"});return}m()&&(o.value=e,j.value="Ln 1, Col 1",De(e,r.get(e).content),k())}}async function oe(e){const t=r.get(e),n=t.content,a=await c.plugins.writeFile(s,e,n);return t.saved=n,!!a?.restartRequired}function ie(e,t){e&&(V.value=!0),F.value=e?`Saved ${t} - server code: restart the server to apply.`:`Saved ${t} - refresh the admin tab to see it.`,k(),x()}async function W(e=o.peek()){if(!(!e||!O(e)||w.peek())){w.value=!0;try{const t=await oe(e);E.toast(t?"Saved. Restart the server to apply.":"Saved.",{type:t?"warning":"success"}),ie(t,e)}catch(t){E.toast(`Not saved: ${t.message||t}`,{type:"error"})}finally{w.value=!1}}}async function re(){const e=N();if(!e.length||w.peek())return;w.value=!0;let t=!1;const n=[];for(const a of e)try{t=await oe(a)||t}catch(l){n.push(`${a}: ${l.message||l}`)}w.value=!1,n.length?E.toast(`Some files were not saved - ${n.join("; ")}`,{type:"error",duration:8e3}):E.toast(`Saved ${e.length===1?"1 file":`${e.length} files`}.`,{type:t?"warning":"success"}),ie(t,e.length===1?e[0]:`${e.length-n.length} files`)}async function le(e=o.peek()){if(!e||!O(e)||!await E.confirm(`Throw away your changes to ${T(e)}? It goes back to what is saved.`))return;const t=r.get(e);t.content=t.saved,e===o.peek()&&h?.setValue(t.saved),k()}const _=new Set;u(()=>[..._].forEach(e=>{try{e.close(!0)}catch{}}));function Q({title:e,initial:t="",from:n="",submitText:a,help:l}){return new Promise(Ae=>{const A=M.observable(t),be=M.computed(()=>Ye(A.value,{existing:b.value.map(D=>D.path),from:n})),Ie=M.computed(()=>n&&A.value.trim()===n),we={value:A,err:be,cannot:M.computed(()=>!!be.value||Ie.value),submitText:a,help:l},y=E.slideover({title:T(e),size:"sm",position:"right"}),$=Xe(`
10
10
  <div class="pcx-form">
11
11
  <div>
12
12
  <label class="form-label" for="pcx-f-path">File path
@@ -20,4 +20,4 @@ import{api as u}from"../api.js";import{reportRestart as ze}from"../lib/server-re
20
20
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
21
21
  <button type="button" class="btn btn-sm btn-primary" data-on-click="ok" data-bind-disabled="cannot.value" data-bind-text="submitText"></button>
22
22
  </div>
23
- </div>`);let we=null;const Ie=M.applyBindings(be,$,{methods:{ok:()=>{be.cannot.peek()||(we=P.peek().trim(),J=!0,y.close())},cancel:()=>y.close()}});y.element.appendChild($);const je=Se($);_.add(y),Ue(y,()=>{Ie.dispose(),je(),_.delete(y),Pe(we)});const Be=y.close.bind(y);let J=!1,X=!1;y.close=async D=>{if(!J&&D!==!0&&P.peek().trim()!==t.trim()){if(X)return;X=!0;const Ke=await E.confirm("Discard the path you typed?");if(X=!1,!Ke)return}return J=!0,Be()},$.querySelector("#pcx-f-path").addEventListener("keydown",D=>{D.key==="Enter"&&(D.preventDefault(),$.querySelector('[data-on-click="ok"]').click())}),I.scan($),y.open();const ke=$.querySelector("#pcx-f-path");ke.focus();const xe=t.lastIndexOf(".");xe>t.lastIndexOf("/")&&ke.setSelectionRange(t.lastIndexOf("/")+1,xe)})}async function G(e=""){const t=await Q({title:"New file",initial:e?`${e}/`:"admin/",submitText:"Create",help:"Created empty and opened. An admin/ file applies on a refresh; anything else on a restart."});if(!(!t||!m()))try{await u.plugins.writeFile(s,t,""),i.set(t,{content:"",saved:""}),await x(),await g(t)}catch(n){E.toast(`Could not create ${t}: ${n.message||n}`,{type:"error"})}}async function De(e){if(ee.includes(e))return;const t=await Q({title:`Rename ${e}`,initial:e,from:e,submitText:"Rename",help:"Your unsaved changes to it come along."});if(!t||t===e||!m())return;const n=i.get(e)||{content:(await u.plugins.readFile(s,e)).content,saved:null};try{await u.plugins.writeFile(s,t,n.content),await u.plugins.deleteFile(s,e),i.delete(e),i.set(t,{content:n.content,saved:n.content});const a=o.peek()===e;a&&(o.value=null),await x(),a&&await g(t),k(),E.toast(`Renamed to ${t}.`,{type:"success"})}catch(a){E.toast(`Rename failed: ${a.message||a}`,{type:"error"}),x()}}async function Le(e){const t=e.lastIndexOf("."),n=t>e.lastIndexOf("/")?`${e.slice(0,t)}-copy${e.slice(t)}`:`${e}-copy`,a=await Q({title:`Duplicate ${e}`,initial:n,submitText:"Duplicate",help:"The copy is saved with the text shown in the editor now."});if(!(!a||!m()))try{const l=i.get(e)?.content??(await u.plugins.readFile(s,e)).content;await u.plugins.writeFile(s,a,l),i.set(a,{content:l,saved:l}),await x(),await g(a)}catch(l){E.toast(`Could not duplicate: ${l.message||l}`,{type:"error"})}}async function Te(e){if(ee.includes(e))return;const t=F(e)?" Your unsaved changes to it go too.":"";if(await E.confirm(`Delete ${T(e)}? This cannot be undone.${t}`))try{if(await u.plugins.deleteFile(s,e),i.delete(e),o.peek()===e){o.value=null;try{h?.destroy()}catch{}h=null,Y.replaceChildren(),j.value=""}E.toast(`Deleted ${e}.`,{type:"success"}),k(),await x()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}}async function Fe(e){try{await navigator.clipboard.writeText(e),E.toast("Path copied.",{type:"success"})}catch{E.toast(e,{type:"info"})}}async function ce(){const e=N().length;if(await E.confirm((e?`${e===1?"A file has":`${e} files have`} unsaved changes - they will NOT be included. `:"")+"Restart the server now? Server-side plugin code applies when it starts. The admin is unreachable for a few seconds.",{title:"Restart server",confirmText:"Restart"}))try{const n=await u.plugins.restart();n.supervised||E.toast("No supervisor detected - if the server does not come back, start it by hand.",{type:"warning"}),await ze(n,{onStatus:a=>{A.value=a}}),V.value=!1}catch(n){E.toast(`Restart failed: ${n.message||n}`,{type:"error"})}}function ue(e){if(!e)return[];const t=ee.includes(e),n=F(e),a=e.includes("/")?e.slice(0,e.lastIndexOf("/")):"";return[{header:e},{label:"Open",icon:"file-text",disabled:o.peek()===e,action:()=>g(e)},{label:"Save",icon:"save",disabled:!n,action:()=>W(e)},{label:"Revert changes\u2026",icon:"rotate-ccw",disabled:!n,action:()=>le(e)},{type:"divider"},{label:"Copy path",icon:"copy",action:()=>Fe(e)},{label:`New file in ${a?`${a}/`:"the plugin"}\u2026`,icon:"plus",action:()=>G(a)},{label:"Duplicate\u2026",icon:"copy",action:()=>Le(e)},{label:t?"Rename - required file":"Rename\u2026",icon:"edit-3",disabled:t,action:()=>De(e)},{type:"divider"},{label:t?"Delete - required file":"Delete\u2026",icon:"trash",danger:!0,disabled:t,action:()=>Te(e)}]}const C=p.querySelector(".pcx-list");C.innerHTML=Ge;const Ne=M.applyBindings(Ce,p,{methods:{save:()=>W(),revert:()=>le(),newFile:()=>G(),restart:()=>ce()}});c(()=>Ne.dispose()),c(Se(p,{position:"bottom"}));const qe=E.contextMenu(C,{cursor:"glyph",match:".pcx-file",inherit:!1,density:"compact",items:e=>ue(e.dataset.path)});c(()=>qe?.destroy?.());const B=p.querySelector(".pcx-more"),Oe=E.contextMenu(B.parentElement,{cursor:"glyph",match:".pcx-more",inherit:!1,density:"compact",items:()=>ue(o.peek())});c(()=>Oe?.destroy?.());const de=()=>Z(B);B.addEventListener("click",de),c(()=>B.removeEventListener("click",de));const pe=e=>{const t=e.target.closest(".pcx-file");if(!t)return;const n=e.target.closest(".pcx-kebab");if(n){Z(n);return}g(t.dataset.path)},fe=e=>{const t=e.target.closest(".pcx-file");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.path))};C.addEventListener("click",pe),C.addEventListener("keydown",fe),c(()=>{C.removeEventListener("click",pe),C.removeEventListener("keydown",fe)});const ve=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!m()||e.target?.closest?.(".pcx-form")||(e.preventDefault(),e.stopPropagation(),e.shiftKey?ie():W())};document.addEventListener("keydown",ve,!0),c(()=>document.removeEventListener("keydown",ve,!0)),K(r,{id:"pcode-back",icon:"arrow-left",title:"Back to the Marketplace",onClick:()=>{location.hash="#/plugins"}}),K(r,{id:"pcode-new",icon:"plus",title:"New file",onClick:()=>G()}),K(r,{id:"pcode-saveall",icon:"save",title:"Save all (Ctrl+Shift+S)",onClick:()=>ie()}),K(r,{id:"pcode-restart",icon:"refresh-cw",title:"Restart server",onClick:()=>ce()});const me=()=>{const e=Ye(i);try{e?S.set(L(s),{...e,at:new Date().toISOString()}):S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}},ye=e=>{N().length&&(me(),e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ye),c(()=>window.removeEventListener("beforeunload",ye)),f={path:location.hash.replace(/^#/,"").split("?")[0]||`/plugins/code/${s}`,hash:location.hash,dirtyCount:()=>N().length,dispose(){z||(me(),z=!0,v.splice(0).reverse().forEach(e=>{try{e()}catch{}}))}},document.title=`${s} code - Domma CMS`,$e(r.get(0),s),u.plugins.list().then(e=>{const t=(Array.isArray(e)?e:[]).find(n=>n.name===s);t?.displayName&&m()&&($e(r.get(0),t.displayName),document.title=`${t.displayName} code - Domma CMS`)}).catch(()=>{}),I.scan(p),await x(),!m())return;let O=null;try{O=S.get(L(s))}catch{}if(O?.files&&Object.keys(O.files).length){try{S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}const e={};if(await Promise.all(Object.keys(O.files).map(async n=>{e[n]=await u.plugins.readFile(s,n).then(a=>a.content).catch(()=>null)})),!m())return;const t=_e(O,e);if(t.restore.length){const n=t.restore.length,a=[t.changed.length?` Changed on the server since: ${t.changed.map(T).join(", ")} - restoring puts your version back over it (unsaved until you save).`:"",t.gone.length?` No longer there: ${t.gone.map(T).join(", ")} (left out).`:""].join("");if(await E.confirm(`You left ${n===1?"a file":`${n} files`} unsaved here (${t.restore.map(l=>T(l.path)).join(", ")}). Restore ${n===1?"it":"them"}?${a}`)&&m()){for(const l of t.restore)i.set(l.path,{content:l.content,saved:l.saved});k(),await g(t.restore[0].path),A.value=`Restored ${n===1?"1 unsaved file":`${n} unsaved files`} - save to keep them.`;return}}}const he=b.peek().find(e=>e.path==="plugin.js")||b.peek()[0];he&&await g(he.path)}};
23
+ </div>`);let ke=null;const je=M.applyBindings(we,$,{methods:{ok:()=>{we.cannot.peek()||(ke=A.peek().trim(),J=!0,y.close())},cancel:()=>y.close()}});y.element.appendChild($);const Be=$e($);_.add(y),Ve(y,()=>{je.dispose(),Be(),_.delete(y),Ae(ke)});const Ke=y.close.bind(y);let J=!1,X=!1;y.close=async D=>{if(!J&&D!==!0&&A.peek().trim()!==t.trim()){if(X)return;X=!0;const ze=await E.confirm("Discard the path you typed?");if(X=!1,!ze)return}return J=!0,Ke()},$.querySelector("#pcx-f-path").addEventListener("keydown",D=>{D.key==="Enter"&&(D.preventDefault(),$.querySelector('[data-on-click="ok"]').click())}),I.scan($),y.open();const xe=$.querySelector("#pcx-f-path");xe.focus();const Ee=t.lastIndexOf(".");Ee>t.lastIndexOf("/")&&xe.setSelectionRange(t.lastIndexOf("/")+1,Ee)})}async function G(e=""){const t=await Q({title:"New file",initial:e?`${e}/`:"admin/",submitText:"Create",help:"Created empty and opened. An admin/ file applies on a refresh; anything else on a restart."});if(!(!t||!m()))try{await c.plugins.writeFile(s,t,""),r.set(t,{content:"",saved:""}),await x(),await g(t)}catch(n){E.toast(`Could not create ${t}: ${n.message||n}`,{type:"error"})}}async function Le(e){if(ee.includes(e))return;const t=await Q({title:`Rename ${e}`,initial:e,from:e,submitText:"Rename",help:"Your unsaved changes to it come along."});if(!t||t===e||!m())return;const n=r.get(e)||{content:(await c.plugins.readFile(s,e)).content,saved:null};try{await c.plugins.writeFile(s,t,n.content),await c.plugins.deleteFile(s,e),r.delete(e),r.set(t,{content:n.content,saved:n.content});const a=o.peek()===e;a&&(o.value=null),await x(),a&&await g(t),k(),E.toast(`Renamed to ${t}.`,{type:"success"})}catch(a){E.toast(`Rename failed: ${a.message||a}`,{type:"error"}),x()}}async function Te(e){const t=e.lastIndexOf("."),n=t>e.lastIndexOf("/")?`${e.slice(0,t)}-copy${e.slice(t)}`:`${e}-copy`,a=await Q({title:`Duplicate ${e}`,initial:n,submitText:"Duplicate",help:"The copy is saved with the text shown in the editor now."});if(!(!a||!m()))try{const l=r.get(e)?.content??(await c.plugins.readFile(s,e)).content;await c.plugins.writeFile(s,a,l),r.set(a,{content:l,saved:l}),await x(),await g(a)}catch(l){E.toast(`Could not duplicate: ${l.message||l}`,{type:"error"})}}async function Fe(e){if(ee.includes(e))return;const t=O(e)?" Your unsaved changes to it go too.":"";if(await E.confirm(`Delete ${T(e)}? This cannot be undone.${t}`))try{if(await c.plugins.deleteFile(s,e),r.delete(e),o.peek()===e){o.value=null;try{h?.destroy()}catch{}h=null,Y.replaceChildren(),j.value=""}E.toast(`Deleted ${e}.`,{type:"success"}),k(),await x()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}}async function Oe(e){try{await navigator.clipboard.writeText(e),E.toast("Path copied.",{type:"success"})}catch{E.toast(e,{type:"info"})}}async function ce(){const e=N().length;if(await E.confirm((e?`${e===1?"A file has":`${e} files have`} unsaved changes - they will NOT be included. `:"")+"Restart the server now? Server-side plugin code applies when it starts. The admin is unreachable for a few seconds.",{title:"Restart server",confirmText:"Restart"}))try{const n=await c.plugins.restart();n.supervised||E.toast("No supervisor detected - if the server does not come back, start it by hand.",{type:"warning"}),await He(n,{onStatus:a=>{F.value=a}}),V.value=!1}catch(n){E.toast(`Restart failed: ${n.message||n}`,{type:"error"})}}function ue(e){if(!e)return[];const t=ee.includes(e),n=O(e),a=e.includes("/")?e.slice(0,e.lastIndexOf("/")):"";return[{header:e},{label:"Open",icon:"file-text",disabled:o.peek()===e,action:()=>g(e)},{label:"Save",icon:"save",disabled:!n,action:()=>W(e)},{label:"Revert changes\u2026",icon:"rotate-ccw",disabled:!n,action:()=>le(e)},{type:"divider"},{label:"Copy path",icon:"copy",action:()=>Oe(e)},{label:`New file in ${a?`${a}/`:"the plugin"}\u2026`,icon:"plus",action:()=>G(a)},{label:"Duplicate\u2026",icon:"copy",action:()=>Te(e)},{label:t?"Rename - required file":"Rename\u2026",icon:"edit-3",disabled:t,action:()=>Le(e)},{type:"divider"},{label:t?"Delete - required file":"Delete\u2026",icon:"trash",danger:!0,disabled:t,action:()=>Fe(e)}]}const C=p.querySelector(".pcx-list");C.innerHTML=Je;const Ne=M.applyBindings(Re,p,{methods:{save:()=>W(),revert:()=>le(),newFile:()=>G(),restart:()=>ce()}});u(()=>Ne.dispose()),u($e(p,{position:"bottom"}));const qe=E.contextMenu(C,{cursor:"glyph",match:".pcx-file",inherit:!1,density:"compact",items:e=>ue(e.dataset.path)});u(()=>qe?.destroy?.());const B=p.querySelector(".pcx-more"),Pe=E.contextMenu(B.parentElement,{cursor:"glyph",match:".pcx-more",inherit:!1,density:"compact",items:()=>ue(o.peek())});u(()=>Pe?.destroy?.());const de=()=>Z(B);B.addEventListener("click",de),u(()=>B.removeEventListener("click",de));const pe=e=>{const t=e.target.closest(".pcx-file");if(!t)return;const n=e.target.closest(".pcx-kebab");if(n){Z(n);return}g(t.dataset.path)},fe=e=>{const t=e.target.closest(".pcx-file");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.path))};C.addEventListener("click",pe),C.addEventListener("keydown",fe),u(()=>{C.removeEventListener("click",pe),C.removeEventListener("keydown",fe)});const ve=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!m()||e.target?.closest?.(".pcx-form")||(e.preventDefault(),e.stopPropagation(),e.shiftKey?re():W())};document.addEventListener("keydown",ve,!0),u(()=>document.removeEventListener("keydown",ve,!0)),K(i,{id:"pcode-back",icon:"arrow-left",title:"Back to the Marketplace",onClick:()=>{location.hash="#/plugins"}});let me=!1;try{me=(await c.plugins.policy())?.signedOnly===!0}catch{}me?F.value="Read only: this site runs plugins signed by the Marketplace, so their code cannot be changed here.":(K(i,{id:"pcode-new",icon:"plus",title:"New file",onClick:()=>G()}),K(i,{id:"pcode-saveall",icon:"save",title:"Save all (Ctrl+Shift+S)",onClick:()=>re()})),K(i,{id:"pcode-restart",icon:"refresh-cw",title:"Restart server",onClick:()=>ce()});const ye=()=>{const e=We(r);try{e?S.set(L(s),{...e,at:new Date().toISOString()}):S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}},he=e=>{N().length&&(ye(),e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",he),u(()=>window.removeEventListener("beforeunload",he)),f={path:location.hash.replace(/^#/,"").split("?")[0]||`/plugins/code/${s}`,hash:location.hash,dirtyCount:()=>N().length,dispose(){z||(ye(),z=!0,v.splice(0).reverse().forEach(e=>{try{e()}catch{}}))}},document.title=`${s} code - Domma CMS`,Me(i.get(0),s),c.plugins.list().then(e=>{const t=(Array.isArray(e)?e:[]).find(n=>n.name===s);t?.displayName&&m()&&(Me(i.get(0),t.displayName),document.title=`${t.displayName} code - Domma CMS`)}).catch(()=>{}),I.scan(p),await x(),!m())return;let P=null;try{P=S.get(L(s))}catch{}if(P?.files&&Object.keys(P.files).length){try{S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}const e={};if(await Promise.all(Object.keys(P.files).map(async n=>{e[n]=await c.plugins.readFile(s,n).then(a=>a.content).catch(()=>null)})),!m())return;const t=Qe(P,e);if(t.restore.length){const n=t.restore.length,a=[t.changed.length?` Changed on the server since: ${t.changed.map(T).join(", ")} - restoring puts your version back over it (unsaved until you save).`:"",t.gone.length?` No longer there: ${t.gone.map(T).join(", ")} (left out).`:""].join("");if(await E.confirm(`You left ${n===1?"a file":`${n} files`} unsaved here (${t.restore.map(l=>T(l.path)).join(", ")}). Restore ${n===1?"it":"them"}?${a}`)&&m()){for(const l of t.restore)r.set(l.path,{content:l.content,saved:l.saved});k(),await g(t.restore[0].path),F.value=`Restored ${n===1?"1 unsaved file":`${n} unsaved files`} - save to keep them.`;return}}}const ge=b.peek().find(e=>e.path==="plugin.js")||b.peek()[0];ge&&await g(ge.path)}};
@@ -1,4 +1,4 @@
1
- import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-chrome.js";import{SHAPES as tt,whileLoading as at}from"../lib/loading.js";import{helpPopovers as Le}from"../lib/help-popover.js";import{onceClosed as nt,openMenuAt as Ue,sidebarCount as st,viewLifetime as lt}from"../lib/tool-kit.js";import{pluginAccent as Re}from"../lib/plugin-accent.js";import{reportRestart as it}from"../lib/server-restart.js";import{FILTERS as ot,arrangeCatalogue as rt,arrangePlugins as dt,catalogueAction as te,catalogueRow as ct,checkScaffold as ut,dependencyRows as pt,formatPrice as je,fmtReleased as bt,needsAttention as mt,planLines as ht,pluginRow as ft,pluginStatus as Be,readSettings as vt,settingsFields as De,slugifyPlugin as gt,updateFor as Fe}from"../lib/plugins-arrange.js";const qe=/^#\/plugins\/?(\?.*)?$/,yt="#/plugins",pe="plugins.filter",ae="plugins.draft",wt=`
1
+ import{api as v,getUser as tt}from"../api.js";import{addBannerAction as pe}from"../lib/plugin-chrome.js";import{SHAPES as at,whileLoading as nt}from"../lib/loading.js";import{helpPopovers as Ue}from"../lib/help-popover.js";import{onceClosed as st,openMenuAt as Re,sidebarCount as lt,viewLifetime as it}from"../lib/tool-kit.js";import{pluginAccent as je}from"../lib/plugin-accent.js";import{reportRestart as ot}from"../lib/server-restart.js";import{FILTERS as rt,arrangeCatalogue as dt,arrangePlugins as ct,catalogueAction as te,catalogueRow as ut,checkScaffold as pt,dependencyRows as bt,formatPrice as Be,fmtReleased as mt,needsAttention as ht,planLines as ft,pluginRow as vt,pluginStatus as De,readSettings as gt,settingsFields as Fe,slugifyPlugin as yt,updateFor as qe}from"../lib/plugins-arrange.js";const We=/^#\/plugins\/?(\?.*)?$/,wt="#/plugins",be="plugins.filter",ae="plugins.draft",xt=`
2
2
  <div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
3
3
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
4
4
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -10,7 +10,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
10
10
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
11
11
  </span>
12
12
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
13
- </div>`,xt=`
13
+ </div>`,kt=`
14
14
  <div class="plx-row" data-tool="{{name}}" tabindex="0" data-bind-class="rowCls">
15
15
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
16
16
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -19,7 +19,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
19
19
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
20
20
  </span>
21
21
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
22
- </div>`,kt=`
22
+ </div>`,$t=`
23
23
  <div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
24
24
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
25
25
  <span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
@@ -29,12 +29,12 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
29
29
  </span>
30
30
  <button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
31
31
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
32
- </div>`,$t=`
32
+ </div>`,Et=`
33
33
  <li class="plx-dep">
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");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(`
37
+ </li>`,d=T=>String(T??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function me(T){const m=document.createElement("template");return m.innerHTML=T.trim(),m.content.firstElementChild}const g=it(We);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(T){g.reset();const m=T.get(0).querySelector(".plx")||T.get(0),k=()=>We.test(location.hash)&&m.isConnected,L=M.observable([]),R=M.observable([]),he=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:[]}),fe=M.observable(!0),ne=M.observable(""),b=M.observable("");let ve="all";try{ve=rt.includes(S.get(be))?S.get(be):"all"}catch{}const N=M.observable(ve),V=M.observable(""),j=M.observable("all"),ge=M.observable("");g.add(M.effect(()=>{const e=N.value;try{S.set(be,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([]);g.add(M.effect(()=>{const e=Y.value;se.value=ct(L.value,{filter:N.value,query:V.value,latest:e}).map(t=>({...vt(t,e),accent:je(t.name)}))}));const Pe=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>De(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>ht(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(e)==="update").length),ze=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),le=M.observable([]);g.add(M.effect(()=>{le.value=dt(h.value.items,{filter:j.value,query:ge.value}).map(e=>{const t=ut(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:je(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const _e=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 nt(m.querySelector(".dm-loading-slot"),Promise.all([v.plugins.list(),v.tools.list().catch(()=>[])]),at.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{fe.value=!1}}async function ye(){try{const e=await v.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(),ye()]),lt(wt)};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"?v.tools.update(e.name,{enabled:t,cascade:i}):v.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=ft(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 ot(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 v.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 we(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 xe(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 v.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 ke(e){try{await v.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const $e=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=m.querySelector(".plx-file");async function He(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 v.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 Ee=async()=>{const e=z.files?.[0];z.value="",e&&await He(e)};z.addEventListener("change",Ee),g.add(()=>z.removeEventListener("change",Ee));const G=new Set;let U=null,Se=!1;function Me({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 $=Ue(a),w=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),st(o,()=>{u(),w.dispose(),$(),G.delete(o),U===c&&(U=null)});const x=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,x()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=De(a),l=Fe(a),i=W(a.name),c=qe(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))},w=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),x=M.computed(()=>gt(l,Object.fromEntries(l.map(s=>[s.key,w[s.key].value])))),f=M.observable(!1),O=()=>Object.keys(x.peek().changed).length>0||Object.keys(x.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",mt(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(Le=>`<option value="${d(Le.value)}">${d(Le.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 Ze=s.type==="number"?"number":s.secret?"password":"text",et=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${A}" type="${Ze}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${et} ${K}>${ee}</div>`},r=bt(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(x.value.changed).length||Object.keys(x.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,w[s.key]],[`err${p}`,M.computed(()=>x.value.errors[s.key]||"")]]))},_=me(`
38
38
  <div class="plx-form">
39
39
  <p class="plx-desc"></p>
40
40
  <dl class="plx-facts"></dl>
@@ -65,7 +65,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
65
65
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
66
66
  <span data-icon="check" data-icon-size="13"></span> Save settings</button>
67
67
  </div>
68
- </div>`);_.querySelector(".plx-desc").textContent=a.description||"",_.querySelectorAll(".plx-deps").forEach(s=>{s.innerHTML=$t});const Ge=_.querySelector(".plx-facts");for(const[s,p]of F){const A=document.createElement("dt");A.textContent=s;const Z=document.createElement("dd");Z.textContent=p,Ge.append(A,Z)}async function Oe(){if(C.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await x.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),X.close(!0),k()&&await ie()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await X.close(),G.has(X)||s()},X=Se({title:a.displayName||a.name,size:"md",panel:_,model:C,dirty:O,form:l.length?{kind:"settings",name:a.name,dirty:O,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=y[p.key].peek());return s}}:null,methods:{save:Oe,cancel:()=>X.close(),toggle:ce(()=>B(a,!a.enabled)),update:ce(()=>D(i,{update:!0})),source:ce(()=>ke(a)),exportIt:()=>xe(a)}});_.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Oe())})}function Me(e=null){const t=et(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;c||(a.slug.value=gt(r))}),o=M.observable(!1),$=M.computed(()=>ut(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.value])),L.value.map(r=>r.name))),y=r=>M.computed(()=>$.value.errors[r]||""),w={...a,saving:o,nameErr:y("displayName"),slugErr:y("slug"),descErr:y("description"),authorErr:y("author"),iconErr:y("icon"),cannot:M.computed(()=>o.value||Object.keys($.value.errors).length>0)},f=be(`
68
+ </div>`);_.querySelector(".plx-desc").textContent=a.description||"",_.querySelectorAll(".plx-deps").forEach(s=>{s.innerHTML=Et});const Xe=_.querySelector(".plx-facts");for(const[s,p]of F){const A=document.createElement("dt");A.textContent=s;const Z=document.createElement("dd");Z.textContent=p,Xe.append(A,Z)}async function Ae(){if(C.cannot.peek())return;const{changed:s}=x.peek();f.value=!0;try{await v.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),X.close(!0),k()&&await ie()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ue=s=>async()=>{await X.close(),G.has(X)||s()},X=Me({title:a.displayName||a.name,size:"md",panel:_,model:C,dirty:O,form:l.length?{kind:"settings",name:a.name,dirty:O,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=w[p.key].peek());return s}}:null,methods:{save:Ae,cancel:()=>X.close(),toggle:ue(()=>B(a,!a.enabled)),update:ue(()=>D(i,{update:!0})),source:ue(()=>$e(a)),exportIt:()=>ke(a)}});_.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Ae())})}function Ce(e=null){const t=tt(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;c||(a.slug.value=yt(r))}),o=M.observable(!1),$=M.computed(()=>pt(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.value])),L.value.map(r=>r.name))),w=r=>M.computed(()=>$.value.errors[r]||""),x={...a,saving:o,nameErr:w("displayName"),slugErr:w("slug"),descErr:w("description"),authorErr:w("author"),iconErr:w("icon"),cannot:M.computed(()=>o.value||Object.keys($.value.errors).length>0)},f=me(`
69
69
  <div class="plx-form">
70
70
  <div>
71
71
  <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
@@ -101,7 +101,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
101
101
  <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
102
102
  <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
103
103
  </div>
104
- </div>`);async function O(){if(w.cannot.peek())return;o.value=!0;const{payload:r}=$.peek();try{await x.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await P()}catch(C){E.toast(C.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const F=Se({title:"New plugin",size:"sm",panel:f,model:w,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:O,cancel:()=>F.close()}}),q=f.querySelector("#plx-np-slug");q.addEventListener("input",()=>{c=!!q.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),O())}),f.querySelector("#plx-np-name")?.focus()}function He(e){const t=Q(e);if(!t)return[];const a=W(t.name),n=Fe(t,Y.peek()),l=!!b.peek();return[{header:t.displayName||t.name},{label:De(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>oe(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>B(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>B(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>D(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ke(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>xe(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>we(t.name,t.displayName)}]]}function Ke(e){const t=R.peek().find(n=>n.name===e);if(!t)return[];const a=!!b.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>B(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>B(t,!0,"tool")}]}const Ve=e=>{const t=R.peek().find(a=>a.name===e);t&&B(t,!t.enabled,"tool")};function Ye(e){const t=W(e);if(!t)return[];const a=te(t),n=!!b.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,N.value="all",Ne.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>we(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${je(t)!=="Paid"?` - ${je(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>ye(t)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function Je(e){const t=W(e);if(!t)return;const a=te(t);a==="install"?D(t):a==="update"?D(t,{update:!0}):a==="buy"?ye(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,N.value="all",Ne.activate?.(0))}function re(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});v.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const $=u.target.closest(".plx-kebab");if($){Ue($);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(o.dataset[t]);return}n(o.dataset[t])},c=u=>{const o=u.target.closest(".plx-row");o&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ue(o)):u.key==="Enter"&&u.target===o&&n(o.dataset[t]))};e.addEventListener("click",i),e.addEventListener("keydown",c),v.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const de=m.querySelector(".plx-browse");de.appendChild(be(`
104
+ </div>`);async function O(){if(x.cannot.peek())return;o.value=!0;const{payload:r}=$.peek();try{await v.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await P()}catch(C){E.toast(C.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const F=Me({title:"New plugin",size:"sm",panel:f,model:x,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:O,cancel:()=>F.close()}}),q=f.querySelector("#plx-np-slug");q.addEventListener("input",()=>{c=!!q.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),O())}),f.querySelector("#plx-np-name")?.focus()}function Ke(e){const t=Q(e);if(!t)return[];const a=W(t.name),n=qe(t,Y.peek()),l=!!b.peek();return[{header:t.displayName||t.name},{label:Fe(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>oe(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>B(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>B(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>D(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>$e(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ke(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>xe(t.name,t.displayName)}]]}function Ve(e){const t=R.peek().find(n=>n.name===e);if(!t)return[];const a=!!b.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>B(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>B(t,!0,"tool")}]}const Ye=e=>{const t=R.peek().find(a=>a.name===e);t&&B(t,!t.enabled,"tool")};function Je(e){const t=W(e);if(!t)return[];const a=te(t),n=!!b.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,N.value="all",Ie.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>xe(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${Be(t)!=="Paid"?` - ${Be(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>we(t)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function Qe(e){const t=W(e);if(!t)return;const a=te(t);a==="install"?D(t):a==="update"?D(t,{update:!0}):a==="buy"?we(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,N.value="all",Ie.activate?.(0))}function re(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const $=u.target.closest(".plx-kebab");if($){Re($);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(o.dataset[t]);return}n(o.dataset[t])},c=u=>{const o=u.target.closest(".plx-row");o&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Re(o)):u.key==="Enter"&&u.target===o&&n(o.dataset[t]))};e.addEventListener("click",i),e.addEventListener("keydown",c),g.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const de=m.querySelector(".plx-browse");de.appendChild(me(`
105
105
  <div class="plx-card">
106
106
  <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
107
107
  <p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
@@ -124,4 +124,4 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"
124
124
  <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
125
125
  <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
126
126
  </div>
127
- </div>`)),de.querySelector(".plx-catalogue").innerHTML=kt;const Ce=m.querySelector(".plx-installed");Ce.innerHTML=wt;const Te=m.querySelector(".plx-tools");Te.innerHTML=xt;const Ne=E.tabs(m.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings({plugins:L,tools:R,toolRows:me,catalogue:h,loading:he,loadError:ne,restarting:b,filter:N,query:V,rows:se,counts:We,emptyText:Pe,updateCount:J,updateCountText:M.computed(()=>`${J.value} update${J.value===1?"":"s"}`),browseFilter:j,browseQuery:ve,browseRows:le,browseCounts:ze,siteText:M.computed(()=>h.value.site?`Licensed to ${h.value.site}.`:""),browseState:M.computed(()=>h.value.available===null?"loading":h.value.failed?"error":h.value.available?"list":"file")},m,{methods:{showAll:()=>{N.value="all"},showOn:()=>{N.value="on"},showOff:()=>{N.value="off"},showAttention:()=>{N.value="attention"},bAll:()=>{j.value="all"},bAvailable:()=>{j.value="available"},bInstalled:()=>{j.value="installed"},bUpdates:()=>{j.value="updates"}}});v.add(()=>Qe.dispose()),v.add(Le(m,{position:"bottom"})),v.add(M.effect(()=>{se.value,me.value,le.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),re(Ce,"name",He,e=>oe(e)),re(Te,"tool",Ke,Ve),re(de.querySelector(".plx-catalogue"),"slug",Ye,Je),ue(T,{id:"plugins-guide",icon:"book",title:"Writing a Tool (guide)",onClick:()=>{location.hash="#/tutorials/plugin"}}),ue(T,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),ue(T,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Me()});const Ie=e=>{(b.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ie),v.add(()=>window.removeEventListener("beforeunload",Ie)),v.add(()=>{if(U?.dirty())try{S.set(ae,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}Ee=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(m),await Promise.all([ie(),ge()]),!k())return;let g=null;try{g=S.get(ae)}catch{}if(g?.values&&!Ee){try{S.remove?S.remove(ae):S.set(ae,null)}catch{}const e=g.kind==="new"?"the new plugin you started":`your settings for ${d(Q(g.name)?.displayName||g.name)}`;(g.kind==="new"||Q(g.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(g.kind==="new"?Me(g.values):oe(g.name,g.values))}}};
127
+ </div>`)),de.querySelector(".plx-catalogue").innerHTML=$t;const Te=m.querySelector(".plx-installed");Te.innerHTML=xt;const Ne=m.querySelector(".plx-tools");Ne.innerHTML=kt;const Ie=E.tabs(m.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Ge=M.applyBindings({plugins:L,tools:R,toolRows:he,catalogue:h,loading:fe,loadError:ne,restarting:b,filter:N,query:V,rows:se,counts:Pe,emptyText:ze,updateCount:J,updateCountText:M.computed(()=>`${J.value} update${J.value===1?"":"s"}`),browseFilter:j,browseQuery:ge,browseRows:le,browseCounts:_e,siteText:M.computed(()=>h.value.site?`Licensed to ${h.value.site}.`:""),browseState:M.computed(()=>h.value.available===null?"loading":h.value.failed?"error":h.value.available?"list":"file")},m,{methods:{showAll:()=>{N.value="all"},showOn:()=>{N.value="on"},showOff:()=>{N.value="off"},showAttention:()=>{N.value="attention"},bAll:()=>{j.value="all"},bAvailable:()=>{j.value="available"},bInstalled:()=>{j.value="installed"},bUpdates:()=>{j.value="updates"}}});g.add(()=>Ge.dispose()),g.add(Ue(m,{position:"bottom"})),g.add(M.effect(()=>{se.value,he.value,le.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),re(Te,"name",Ke,e=>oe(e)),re(Ne,"tool",Ve,Ye),re(de.querySelector(".plx-catalogue"),"slug",Je,Qe),pe(T,{id:"plugins-guide",icon:"book",title:"Writing a Tool (guide)",onClick:()=>{location.hash="#/tutorials/plugin"}}),pe(T,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()});let ce=!1;try{ce=(await v.plugins.policy())?.signedOnly===!0}catch{}ce||pe(T,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Ce()});const Oe=e=>{(b.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Oe),g.add(()=>window.removeEventListener("beforeunload",Oe)),g.add(()=>{if(U?.dirty())try{S.set(ae,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}Se=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(m),await Promise.all([ie(),ye()]),!k())return;let y=null;try{y=S.get(ae)}catch{}if(y?.values&&!Se){try{S.remove?S.remove(ae):S.set(ae,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${d(Q(y.name)?.displayName||y.name)}`;(y.kind==="new"&&!ce||Q(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(y.kind==="new"?Ce(y.values):oe(y.name,y.values))}}};
@@ -37,7 +37,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
37
37
  | `booking-pro` | paid | 0.4.1 | More for Booking: online appointments as a video call on your site (optional host recording), Call now - open a booking's call at once or call anyone from a contact or an email (switches on the CMS's instant calls), a person and a room together, classes with places and a waiting list that fills a freed place by itself, repeat bookings, questions on the booking form, a daily limit and one booking per customer every N days. A lapse leaves Booking and every booking as they were. |
38
38
  | `calendar` | paid | 1.15.4 | Events calendar - month, day and list views, online meetings as a video call on your site (with the call's chat kept, and Add people during the call), call anyone now from a contact or an email, all-day and recurring events, sharing with people and roles, RSVPs and an iCal feed. |
39
39
  | `campaigns-pro` | paid | 1.0.2 | Marketing campaigns, one paid Tool: compose a message, send it to an audience imported from Contacts (shared with Surveys), schedule sends, attach an offer that closes by a date or a claim limit. Opens and clicks (cookieless), links tagged for Analytics, a linked Shop discount code, send a survey as a campaign, follow-up sequences, resend to non-openers, A/B subject tests, and a version-1 stats feed other Tools read. |
40
- | `contacts-pro` | paid | 0.9.5 | Contacts, with more to them: your own custom fields, birthdays and ages with the ones coming up and a reminder in the sidebar, and groups with their own emblem and colours. Share a contact, several, or a whole group with people or roles - to view, or to edit. Call a contact now on a video call on your site. Works over the same contacts as the built-in Tool, so nothing moves and nothing is lost. |
40
+ | `contacts-pro` | paid | 0.12.0 | Contacts, with more to them: your own custom fields, birthdays and ages with the ones coming up and a reminder in the sidebar, and groups with their own emblem and colours. Share a contact, several, or a whole group with people or roles - to view, or to edit. Call a contact now on a video call on your site. Works over the same contacts as the built-in Tool, so nothing moves and nothing is lost. |
41
41
  | `data-transfer` | paid | 1.3.3 | Move collections between file and MongoDB storage, back them up and restore them (all or some, merged or replaced) - keeping every entry's id - and clone them (cloned entries get new ids, so references stay unambiguous). Every restore and transfer takes a safety snapshot first. |
42
42
  | `docs` | paid | 2.8.1 | Document editor: Home and editor screens, sharing with Viewers/Editors, public links, import/export, send to page/blog/email, edit lock, Trash, version history, templates. |
43
43
  | `docs-pro` | paid | 0.8.1 | Domma Spaces (publish a Docs folder at `/spaces` with a Kindle-style reader), Cloud copies (Google Drive, OneDrive, Dropbox, S3), Signatures (emailed signing links, typed or drawn, signed copy with a certificate page) and protected public links (expiry date, password). Adds to Docs. |
@@ -1814,6 +1814,7 @@ An upgraded Contacts screen that replaces the built-in one while working on the
1814
1814
  - Permissions: uses the core `contacts` permission and `contacts.manageGroups` (no keys of its own); contacts remain per user.
1815
1815
  - User guide (0.7.5): shown under Documentation > Plugins.
1816
1816
  - Instant calls (0.8.0): switches on the CMS's "Call {name} now…" on contacts (core instant calls), with the recent calls on the contact.
1817
+ - Quick actions (0.12.0): a row at the top of the contact editor - **Email now** (Email Pro's compose window, else the mail program), **Call** (`tel:` the main phone), **Chat now** (a core instant call for that contact - the host tab opens on the click, and their own Join link shows in the editor with Copy / Email it / Join) and **View website** (new tab, http(s) only). Each shows only when the contact has what it needs; Chat now only while instant calls are on for the user (`GET /api/calls/status`).
1817
1818
 
1818
1819
  **Where:** Admin sidebar "Contacts" (replaces the core entry) -> `#/plugins/contacts-pro`. Custom fields and Manage groups under the kebab menu; display settings under the banner cog (per browser).
1819
1820
 
@@ -30,7 +30,7 @@ Controls the HTTP server.
30
30
  | `uploads.maxFileSize` | number | `10485760` (10 MB) | Maximum allowed upload size in bytes |
31
31
  | `logging.level` | string | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`. Defaults to `info` in development, `warn` otherwise. `warn` hides 4xx responses and plugin `log.info` output, so a failed admin action leaves no trace - raise it to `info` if you need to diagnose one. Overridden by the `LOG_LEVEL` env var. |
32
32
  | `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
33
- | `requireSignedPlugins` | boolean | `false` | When `true`, only signed `.dcmsplugin` bundles can be installed - an unsigned bundle (e.g. one exported from another site) is refused even if the installer confirms it. A bundle with a signature that does not verify is always refused, whatever this is set to |
33
+ | `requireSignedPlugins` | boolean | `false` | When `true`, the site runs only plugin code the Marketplace signed: an unsigned `.dcmsplugin` (e.g. one exported from another site) is refused even if the installer confirms it, New plugin is refused, a plugin's files cannot be changed in the code editor (it opens read only), licensed source cannot be read even by a Super Admin, and MongoDB connection options that start a program or read a file (`autoEncryption`, `tls…File`, …) cannot be saved. A bundle with a signature that does not verify is always refused, whatever this is set to. Read fresh on every check, and also as it was at boot, so changing the file on a running site can switch it on but not off. `DOMMA_SIGNED_ONLY=1` in the environment turns it on whatever the file says - Domma CMS Manager sets it on the sites it hosts. Nothing in the admin writes `server.json`. See `server/services/codePolicy.js` |
34
34
 
35
35
  ### Plugin toggles restart the server
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.13",
3
+ "version": "1.8.14",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
package/server/config.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
+ import {fileIn} from './services/safePath.js';
11
12
 
12
13
  const CONFIG_DIR = path.resolve('config');
13
14
 
@@ -28,7 +29,7 @@ function loadJson(name) {
28
29
  * @returns {object}
29
30
  */
30
31
  export function getConfig(name) {
31
- return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, `${name}.json`), 'utf8'));
32
+ return JSON.parse(fs.readFileSync(fileIn(CONFIG_DIR, name, '.json'), 'utf8'));
32
33
  }
33
34
 
34
35
  /**
@@ -39,7 +40,7 @@ export function getConfig(name) {
39
40
  * @returns {void}
40
41
  */
41
42
  export function saveConfig(name, data) {
42
- fs.writeFileSync(path.join(CONFIG_DIR, `${name}.json`), JSON.stringify(data, null, 2) + '\n');
43
+ fs.writeFileSync(fileIn(CONFIG_DIR, name, '.json'), JSON.stringify(data, null, 2) + '\n');
43
44
  }
44
45
 
45
46
  /**
package/server/env.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Load .env - first, before anything reads process.env.
3
+ *
4
+ * Not `import 'dotenv/config'`: that also takes its OPTIONS from the
5
+ * environment (`DOTENV_CONFIG_OVERRIDE`, `DOTENV_CONFIG_PATH`, …), so a value
6
+ * the site's own .env had leaked into a parent's environment could make .env
7
+ * override what the parent set on purpose - such as the Manager's
8
+ * `DOMMA_SIGNED_ONLY` (services/codePolicy.js). .env never overrides here.
9
+ */
10
+ import dotenv from 'dotenv';
11
+
12
+ dotenv.config({override: false});
@@ -58,6 +58,7 @@ import {authenticate, requireAdmin, requirePermission} from '../../middleware/au
58
58
  import {getRoleLevel, invalidate as invalidateRoles, isBaseRole} from '../../services/roles.js';
59
59
  import {getEffectiveLevel} from '../../services/userRoles.js';
60
60
  import {getConfig, saveConfig} from '../../config.js';
61
+ import {riskyConnectionOptions, signedOnly} from '../../services/codePolicy.js';
61
62
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
62
63
  import {ensureFormForCollection} from '../../services/forms.js';
63
64
  import {hooks} from '../../services/hooks.js';
@@ -397,6 +398,14 @@ export async function collectionsRoutes(fastify) {
397
398
  return reply.status(400).send({error: `Connection "${name}" requires type, uri, and database`});
398
399
  }
399
400
  }
401
+ // A signed-only (hosted) site: no option that starts a program or reads a
402
+ // file off this server (services/codePolicy.js).
403
+ if (signedOnly()) {
404
+ for (const [name, conn] of Object.entries(data)) {
405
+ const risky = riskyConnectionOptions(conn);
406
+ if (risky.length) return reply.status(400).send({error: `Connection "${name}": ${risky.join(', ')} cannot be set on this site.`});
407
+ }
408
+ }
400
409
  let stored = {};
401
410
  try { stored = getConfig('connections'); } catch { /* nothing saved yet */ }
402
411
  const {config, error} = restoreConnections(data, stored);
@@ -112,8 +112,7 @@ export async function pluginMarketplaceRoutes(fastify) {
112
112
  // bare "Installation failed." while Install from file showed it.
113
113
  const safe = err.expose === true || [
114
114
  'Plugin already installed', 'Plugin signature invalid', 'Unknown public key',
115
- 'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade',
116
- 'This site accepts signed plugins only'
115
+ 'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade'
117
116
  ].some(m => err.message.startsWith(m));
118
117
  return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
119
118
  }
@@ -2,6 +2,7 @@
2
2
  * Plugins API
3
3
  * GET /api/plugins - list all discovered plugins + state (admin only)
4
4
  * GET /api/plugins/_count - the sidebar badge (`_` is outside the slug alphabet)
5
+ * GET /api/plugins/_policy - {signedOnly}: what the screens may offer (services/codePolicy.js)
5
6
  * POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
6
7
  * PUT /api/plugins/:name - enable/disable, update settings (admin only)
7
8
  * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
@@ -27,6 +28,7 @@ import {getToolStates, planSwitch, requiresOf, switchableUnits, usesOf} from '..
27
28
  import {applyCascade} from './tools.js';
28
29
  import {scaffoldPlugin} from '../../services/pluginScaffold.js';
29
30
  import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
31
+ import {signedOnly, signedOnlyError} from '../../services/codePolicy.js';
30
32
  import {getConfig} from '../../config.js';
31
33
  import path from 'path';
32
34
  import {existsSync, readFileSync} from 'node:fs';
@@ -217,6 +219,10 @@ export async function pluginsRoutes(fastify, opts = {}) {
217
219
  const canUpdate = { preHandler: [authenticate, requirePermission('plugins', 'update')] };
218
220
  const canDevelop = { preHandler: [authenticate, requirePermission('plugins', 'develop')] };
219
221
 
222
+ // A signed-only refusal carries its code so a screen can tell it from a failure.
223
+ const mapErr = (reply, err) => reply.status(err.statusCode || 500)
224
+ .send({ error: err.message, ...(err.code === 'signed-only' && {code: err.code}) });
225
+
220
226
  // List all plugins with their current state
221
227
  fastify.get('/plugins', canRead, async (request) => {
222
228
  const manifests = await discoverPlugins();
@@ -286,6 +292,12 @@ export async function pluginsRoutes(fastify, opts = {}) {
286
292
  return pluginsBadge(plugins, await latestVersions());
287
293
  });
288
294
 
295
+ // What this site lets its admin do with plugin code. A signed-only site
296
+ // (services/codePolicy.js) refuses New plugin, unsigned uploads and code
297
+ // edits - the screens ask so they can leave those off rather than offer
298
+ // them and show the refusal. The refusal is the guard, not this.
299
+ fastify.get('/plugins/_policy', canRead, async () => ({signedOnly: signedOnly()}));
300
+
289
301
  // Scaffold a new plugin from plugins/_template. The plugin is created
290
302
  // pre-enabled so it goes live on the next server restart (its routes and
291
303
  // admin views only register at boot - same restart rule as enable/disable).
@@ -296,7 +308,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
296
308
  savePluginState(result.name, { enabled: true });
297
309
  return { success: true, name: result.name, files: result.files, restartRequired: true };
298
310
  } catch (err) {
299
- return reply.status(err.statusCode || 500).send({ error: err.message });
311
+ return mapErr(reply, err);
300
312
  }
301
313
  });
302
314
 
@@ -377,7 +389,6 @@ export async function pluginsRoutes(fastify, opts = {}) {
377
389
 
378
390
  // ── In-admin code editor (plugins.develop - code execution grade) ──────
379
391
 
380
- const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
381
392
 
382
393
  /*
383
394
  * May this request read a LICENSED plugin's source?
@@ -388,7 +399,9 @@ export async function pluginsRoutes(fastify, opts = {}) {
388
399
  * without opening it on the sites they are distributed to. Level rather
389
400
  * than role name, the same way requireAdmin and canManageUser decide.
390
401
  */
391
- const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0;
402
+ // A signed-only site is a hosted one, where level 0 is the customer - the
403
+ // assumption above does not hold there, so nobody reads licensed source.
404
+ const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0 && !signedOnly();
392
405
  const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
393
406
 
394
407
  // List a plugin's editable files
@@ -465,6 +478,14 @@ export async function pluginsRoutes(fastify, opts = {}) {
465
478
  return reply.code(400).send({error: err.message});
466
479
  }
467
480
 
481
+ // A signed-only site refuses an unsigned file outright - before the
482
+ // "already installed, update it?" question, which would otherwise be
483
+ // asked about something that can never be installed.
484
+ if (!parts.signature && signedOnly()) {
485
+ const err = signedOnlyError();
486
+ return reply.code(err.statusCode).send({error: err.message, code: err.code});
487
+ }
488
+
468
489
  // The slug comes from the manifest inside, never from the filename: a
469
490
  // file can be renamed by anyone, a manifest is what the installer then
470
491
  // validates against what was actually extracted.
@@ -496,6 +517,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
496
517
  });
497
518
  } catch (err) {
498
519
  fastify.log.warn({err}, '[plugins] upload install failed');
520
+ if (err.code === 'signed-only') return mapErr(reply, err);
499
521
  return reply.code(400).send({error: err.message});
500
522
  }
501
523
 
package/server/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Domma dist files are served directly from node_modules/domma-js/public/dist.
5
5
  * Updated: blobs + z-index fixes.
6
6
  */
7
- import 'dotenv/config';
7
+ import './env.js';
8
8
  import Fastify from 'fastify';
9
9
  import jwt from '@fastify/jwt';
10
10
  import staticPlugin from '@fastify/static';
@@ -23,6 +23,7 @@ import path from 'path';
23
23
  import { config } from '../../config.js';
24
24
  import { applyFilter } from '../filterEngine.js';
25
25
  import { withFileLock, writeFileAtomic } from './fileLock.js';
26
+ import {fileIn} from '../safePath.js';
26
27
 
27
28
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
28
29
 
@@ -31,7 +32,7 @@ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
31
32
  // ---------------------------------------------------------------------------
32
33
 
33
34
  function dataPath(slug) {
34
- return path.join(COLLECTIONS_DIR, slug, 'data.json');
35
+ return path.join(fileIn(COLLECTIONS_DIR, slug), 'data.json');
35
36
  }
36
37
 
37
38
  async function read(slug) {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Signed-only sites: who may put server-side code on this site.
3
+ *
4
+ * `config/server.json` `requireSignedPlugins: true` means the only plugin code
5
+ * this site runs is code the Marketplace signed. A hosted site (one a Domma CMS
6
+ * Manager runs) has it on, because its owner holds the top role there and a
7
+ * level-0 user who can run their own code can read the site's secrets - and
8
+ * with JWT_SECRET forge a Manager staff session (services/staffAccess.js).
9
+ * Self-hosted sites leave it off: there the owner IS the host.
10
+ *
11
+ * With it on, every way the admin had of getting its own JavaScript onto the
12
+ * server is refused here, at the service, so a second route or a plugin calling
13
+ * the same service cannot step round it:
14
+ *
15
+ * - installing an unsigned .dcmsplugin (pluginInstaller.installPlugin)
16
+ * - New plugin (pluginScaffold.scaffoldPlugin)
17
+ * - writing or deleting a plugin's files in the code editor (pluginFiles)
18
+ * - reading a LICENSED plugin's source as level 0 - on a hosted site level 0
19
+ * is the customer, not the people who publish the plugin (routes/api/plugins.js)
20
+ *
21
+ * Three things say "on", and any one is enough - none can be undone from the
22
+ * site's own admin:
23
+ *
24
+ * 1. `DOMMA_SIGNED_ONLY=1` in the environment. The Manager sets it when it
25
+ * forks a site it hosts (after the site's .env, so .env cannot unset it).
26
+ * This is the one a file-write bug cannot touch.
27
+ * 2. server.json as it was at boot (config.server). Deleting or rewriting
28
+ * the file while the site runs changes nothing until a restart.
29
+ * 3. server.json now, read fresh - so the Manager can switch a running site
30
+ * on without restarting it.
31
+ *
32
+ * Nothing in the admin or the API writes server.json - it is the host's file.
33
+ * Keep it that way: a route that ever writes it must leave
34
+ * `requireSignedPlugins` exactly as it found it.
35
+ *
36
+ * @module services/codePolicy
37
+ */
38
+ import {config, getConfig} from '../config.js';
39
+
40
+ /** What the admin is told, whatever it tried. Plain words: it is the whole answer. */
41
+ export const SIGNED_ONLY_MESSAGE = 'This site only runs plugins signed by the Marketplace, so plugin code cannot be uploaded, created or edited here. Install plugins from Browse.';
42
+
43
+ /**
44
+ * Does this site refuse any plugin code the Marketplace has not signed?
45
+ *
46
+ * @param {NodeJS.ProcessEnv} [env]
47
+ * @returns {boolean}
48
+ */
49
+ export function signedOnly(env = process.env) {
50
+ if (env.DOMMA_SIGNED_ONLY === '1') return true;
51
+ if (config.server?.requireSignedPlugins === true) return true;
52
+ try {
53
+ return getConfig('server')?.requireSignedPlugins === true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The refusal, as an Error the routes map straight to a reply
61
+ * (`statusCode` 403, `code` 'signed-only', `expose` for the marketplace route).
62
+ *
63
+ * @returns {Error}
64
+ */
65
+ export function signedOnlyError() {
66
+ return Object.assign(new Error(SIGNED_ONLY_MESSAGE), {statusCode: 403, code: 'signed-only', expose: true});
67
+ }
68
+
69
+ /** Throw the refusal when the site is signed-only. */
70
+ export function assertMayChangeCode() {
71
+ if (signedOnly()) throw signedOnlyError();
72
+ }
73
+
74
+ /**
75
+ * MongoDB client options that reach outside the database: start a program
76
+ * (`autoEncryption.extraOptions.mongocryptdSpawnPath` / `...SpawnArgs`), load a
77
+ * native library (`cryptSharedLibPath`) or read a file off this server
78
+ * (`tlsCAFile`, `tlsCertificateKeyFile`, the old `ssl*` names). The admin
79
+ * writes connections.json (PUT /api/collections/connections) and the site -
80
+ * and the Manager's Data Manager - hand its `options` to `new MongoClient`.
81
+ * Today the spawn needs `mongodb-client-encryption`, which is not installed;
82
+ * this keeps it that way on a signed-only site whatever gets installed later.
83
+ */
84
+ const RISKY_MONGO_OPTION = /^(autoEncryption|extraOptions|.*File|.*Path|.*SpawnArgs|ssl(CA|Cert|Key|CRL|PEMKeyFile|PEMKeyPassword)|cryptShared.*|mongocryptd.*)$/i;
85
+
86
+ /**
87
+ * The risky option names in one stored connection (its `options`, at any depth,
88
+ * and its URI's query string). Empty when it is fine.
89
+ *
90
+ * @param {{uri?: string, options?: object}} conn
91
+ * @returns {string[]}
92
+ */
93
+ export function riskyConnectionOptions(conn) {
94
+ const found = [];
95
+ const walk = (obj, at) => {
96
+ if (!obj || typeof obj !== 'object') return;
97
+ for (const [k, v] of Object.entries(obj)) {
98
+ const here = at ? `${at}.${k}` : k;
99
+ if (RISKY_MONGO_OPTION.test(k)) found.push(here);
100
+ else walk(v, here);
101
+ }
102
+ };
103
+ walk(conn?.options, 'options');
104
+ const query = String(conn?.uri || '').split('?')[1] || '';
105
+ for (const pair of query.split('&')) {
106
+ let key = pair.split('=')[0];
107
+ try { key = decodeURIComponent(key); } catch { /* keep it raw */ }
108
+ if (key && RISKY_MONGO_OPTION.test(key)) found.push(`uri ${key}`);
109
+ }
110
+ return found;
111
+ }
@@ -14,6 +14,7 @@ import {adapterForStorage, describeAdapter, getAdapter, invalidate, isFixedToFil
14
14
  import * as cache from './cache/index.js';
15
15
  import {typedEntryData} from './entryValues.js';
16
16
  import {toCsv} from '../../public/js/collection-export.mjs';
17
+ import {fileIn} from './safePath.js';
17
18
 
18
19
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
19
20
  const TOOL_TRASH_SLUGS = new Set(['notes-notes', 'todo-items', 'contacts-contacts']);
@@ -27,7 +28,7 @@ async function ensureDir() {
27
28
  }
28
29
 
29
30
  function collectionDir(slug) {
30
- return path.join(COLLECTIONS_DIR, slug);
31
+ return fileIn(COLLECTIONS_DIR, slug);
31
32
  }
32
33
 
33
34
  function schemaPath(slug) {
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
9
  import {getPluginComponents} from './hooks.js';
10
+ import {fileIn} from './safePath.js';
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
  const COMPONENTS_DIR = path.resolve(__dirname, '../../content/components');
@@ -28,7 +29,7 @@ function assertValidName(name) {
28
29
  * @param {string} name - Component name (without extension).
29
30
  * @returns {string}
30
31
  */
31
- function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc`); }
32
+ function componentFilePath(name) { return fileIn(COMPONENTS_DIR, name, '.dmc'); }
32
33
 
33
34
  /**
34
35
  * Absolute filesystem path to a component's companion .meta.json file.
@@ -36,7 +37,7 @@ function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc
36
37
  * @param {string} name - Component name (without extension).
37
38
  * @returns {string}
38
39
  */
39
- function componentMetaPath(name) { return path.join(COMPONENTS_DIR, `${name}.meta.json`); }
40
+ function componentMetaPath(name) { return fileIn(COMPONENTS_DIR, name, '.meta.json'); }
40
41
 
41
42
  /**
42
43
  * Split a .dmc source string into its four SFC blocks.
@@ -35,6 +35,14 @@ export async function initialise(connectionsConfig) {
35
35
  continue;
36
36
  }
37
37
 
38
+ // Written before the site went signed-only, or by hand: not used there.
39
+ const {riskyConnectionOptions, signedOnly} = await import('./codePolicy.js');
40
+ const risky = signedOnly() ? riskyConnectionOptions(cfg) : [];
41
+ if (risky.length) {
42
+ console.error(`[connectionManager] Skipping connection "${name}": ${risky.join(', ')} not allowed on a signed-only site`);
43
+ continue;
44
+ }
45
+
38
46
  try {
39
47
  const client = new MongoClient(cfg.uri, cfg.options || {});
40
48
  await client.connect();
@@ -501,7 +501,7 @@ function urlPathToFilePath(urlPath) {
501
501
  const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
502
502
  const result = path.join(PAGES_DIR, normalised + '.md');
503
503
  const resolved = path.resolve(result);
504
- if (!resolved.startsWith(path.resolve(PAGES_DIR))) {
504
+ if (!resolved.startsWith(path.resolve(PAGES_DIR) + path.sep)) {
505
505
  throw new Error('Invalid path: outside content directory');
506
506
  }
507
507
  return result;
@@ -20,6 +20,7 @@ import path from 'path';
20
20
  import {fileURLToPath} from 'url';
21
21
  import * as cache from './cache/index.js';
22
22
  import {cssClassProblems} from '../../public/js/css-classes.mjs';
23
+ import {fileIn} from './safePath.js';
23
24
 
24
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
26
  const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
@@ -66,7 +67,7 @@ export async function ensureContextMenusDir() {
66
67
  }
67
68
 
68
69
  function ctxPath(slug) {
69
- return path.join(CTX_DIR, `${slug}.json`);
70
+ return fileIn(CTX_DIR, slug, '.json');
70
71
  }
71
72
 
72
73
  async function readCtxFile(slug) {
@@ -8,6 +8,7 @@ import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
9
  import * as cache from './cache/index.js';
10
10
  import {buttonSettingsProblems, formClassProblems} from '../../public/js/form-submit.mjs';
11
+ import {fileIn} from './safePath.js';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -30,7 +31,7 @@ export async function ensureFormsDir() {
30
31
  * @throws {Error} If the form file does not exist or cannot be parsed.
31
32
  */
32
33
  export async function readForm(slug) {
33
- const file = path.join(FORMS_DIR, `${slug}.json`);
34
+ const file = fileIn(FORMS_DIR, slug, '.json');
34
35
  return JSON.parse(await fs.readFile(file, 'utf8'));
35
36
  }
36
37
 
@@ -43,7 +44,7 @@ export async function readForm(slug) {
43
44
  */
44
45
  export async function writeForm(slug, data) {
45
46
  await ensureFormsDir();
46
- const file = path.join(FORMS_DIR, `${slug}.json`);
47
+ const file = fileIn(FORMS_DIR, slug, '.json');
47
48
  await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
48
49
  await cache.invalidateTags([`form:${slug}`]);
49
50
  }
@@ -83,7 +84,7 @@ export async function listForms() {
83
84
  * @throws {Error} If the form file does not exist.
84
85
  */
85
86
  export async function deleteForm(slug) {
86
- await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
87
+ await fs.unlink(fileIn(FORMS_DIR, slug, '.json'));
87
88
  await cache.invalidateTags([`form:${slug}`]);
88
89
  }
89
90
 
@@ -21,6 +21,7 @@ import {
21
21
  LEGACY_STYLE_HOVER, LEGACY_STYLE_ACTIVE, legacyStyleHighlight,
22
22
  PRESET_COLOUR_VARS, PRESET_COLOUR_FALLBACKS, colourToCss, cssClassProblems
23
23
  } from '../../public/js/menu-decor.mjs';
24
+ import {fileIn} from './safePath.js';
24
25
 
25
26
  /**
26
27
  * Bust the response-cache tags that menu changes affect. Public pages are
@@ -472,7 +473,7 @@ export async function ensureMenusDir() {
472
473
  }
473
474
 
474
475
  function menuPath(slug) {
475
- return path.join(MENUS_DIR, `${slug}.json`);
476
+ return fileIn(MENUS_DIR, slug, '.json');
476
477
  }
477
478
 
478
479
  async function readMenuFile(slug) {
@@ -13,13 +13,16 @@
13
13
  * exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
14
14
  * the manager: the people who publish the paid plugins are the people who need
15
15
  * to read and edit them. Customer sites are all level 1, so nothing changes
16
- * there - see the `allowLicensed` argument, set from the request's user.
16
+ * there - see the `allowLicensed` argument, set from the request's user. (Hosted
17
+ * owners are in fact level 0 on their own site: on a signed-only site the route
18
+ * never allows licensed source - services/codePolicy.js.)
17
19
  *
18
20
  * Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
19
21
  * 413 too large) for direct mapping in the route layer.
20
22
  */
21
23
  import fs from 'fs/promises';
22
24
  import path from 'path';
25
+ import {assertMayChangeCode} from './codePolicy.js';
23
26
 
24
27
  const PLUGINS_DIR = path.resolve('plugins');
25
28
 
@@ -200,6 +203,8 @@ export async function readPluginFile(name, relPath, {allowLicensed = false} = {}
200
203
  * @returns {Promise<{path: string, restartRequired: boolean}>}
201
204
  */
202
205
  export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
206
+ // A signed-only site runs the Marketplace's code as signed: nothing here may change it.
207
+ assertMayChangeCode();
203
208
  const pluginDir = await resolvePluginDir(name, allowLicensed);
204
209
  const abs = await resolveFilePath(pluginDir, relPath);
205
210
 
@@ -240,6 +245,8 @@ export async function writePluginFile(name, relPath, content, {allowLicensed = f
240
245
  * @returns {Promise<{path: string}>}
241
246
  */
242
247
  export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
248
+ // A signed-only site runs the Marketplace's code as signed: nothing here may change it.
249
+ assertMayChangeCode();
243
250
  const pluginDir = await resolvePluginDir(name, allowLicensed);
244
251
  const abs = await resolveFilePath(pluginDir, relPath);
245
252
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
@@ -16,9 +16,9 @@ import {readFileSync} from 'fs';
16
16
  import path from 'path';
17
17
  import zlib from 'zlib';
18
18
  import {fileURLToPath} from 'url';
19
- import {getConfig} from '../config.js';
20
19
  import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
21
20
  import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
21
+ import {signedOnly, signedOnlyError} from './codePolicy.js';
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
24
  // Paths
@@ -74,18 +74,14 @@ MCowBQYDK2VwAyEAqAlx1lfHeCaNiwJmoXZMDY56yafU2GlpNG3VInw9Q14=
74
74
  * Does this site refuse anything the marketplace has not signed?
75
75
  *
76
76
  * Default false, because a CMS whose own scaffolder produces unsigned plugins
77
- * cannot refuse unsigned plugins without refusing its user's own work. A fleet
78
- * site should set it true: everything it receives arrives by push-copy, and
79
- * nothing legitimate is ever uploaded there.
77
+ * cannot refuse unsigned plugins without refusing its user's own work. A hosted
78
+ * site has it on (services/codePolicy.js - the one place that decides, and the
79
+ * other doors it closes).
80
80
  *
81
81
  * @returns {boolean}
82
82
  */
83
83
  function requireSignedPlugins() {
84
- try {
85
- return getConfig('server')?.requireSignedPlugins === true;
86
- } catch {
87
- return false;
88
- }
84
+ return signedOnly();
89
85
  }
90
86
 
91
87
  /**
@@ -348,12 +344,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
348
344
  // Step 1 - Verify signature, or establish that there is deliberately none.
349
345
  const unsigned = !signature;
350
346
  if (unsigned) {
347
+ // Signed-only first: asking the admin to confirm something the site will
348
+ // refuse anyway would be a question with no right answer.
349
+ if (requireSignedPlugins()) throw signedOnlyError();
351
350
  if (!allowUnsigned) {
352
351
  throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
353
352
  }
354
- if (requireSignedPlugins()) {
355
- throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
356
- }
357
353
  }
358
354
 
359
355
  const pubKeyPem = unsigned ? null : trustedKey(publicKeyId, 'bundle');
@@ -396,7 +392,14 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
396
392
  throw err;
397
393
  }
398
394
 
399
- // Step 4 - Validate manifest
395
+ // Step 4 - Validate manifest. discoverPlugins() names a plugin after its
396
+ // folder, so it cannot tell a bundle installed under someone else's slug -
397
+ // the manifest inside must name this slug itself (as an upgrade checks).
398
+ const own = await validatePluginDir(destDir, slug);
399
+ if (!own.ok) {
400
+ await fs.rm(destDir, { recursive: true, force: true });
401
+ throw new Error(`Plugin manifest validation failed after extraction: ${slug} (${own.reason})`);
402
+ }
400
403
  const manifests = await discoverPlugins();
401
404
  const manifest = manifests.find(m => m.name === slug);
402
405
  if (!manifest) {
@@ -12,6 +12,7 @@
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
14
  import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
15
+ import {assertMayChangeCode} from './codePolicy.js';
15
16
 
16
17
  const PLUGINS_DIR = path.resolve('plugins');
17
18
  const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
@@ -71,6 +72,8 @@ export function validateScaffoldInput(input = {}) {
71
72
  * @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
72
73
  */
73
74
  export async function scaffoldPlugin(input) {
75
+ // A new plugin is new server code: a signed-only site makes none (services/codePolicy.js).
76
+ assertMayChangeCode();
74
77
  const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
75
78
 
76
79
  try {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One name, one file, inside one folder.
3
+ *
4
+ * Fastify DECODES route params, so `/api/forms/..%2F..%2Fconfig%2Fserver`
5
+ * arrives as the slug `../../config/server`. A store that builds its file as
6
+ * `path.join(DIR, `${slug}.json`)` then reads and writes `config/server.json` -
7
+ * which is how a site admin could rewrite the host's own settings (switching
8
+ * `requireSignedPlugins` off, unlocking a Tool) through an ordinary form save.
9
+ * Routes check slugs in most places; this is the check at the file, so a route
10
+ * that forgets is still safe.
11
+ *
12
+ * @module services/safePath
13
+ */
14
+ import path from 'path';
15
+
16
+ /**
17
+ * The file `<dir>/<name><ext>`, refusing any name that is not one plain path
18
+ * segment (a separator, `.`/`..`, a NUL, empty, or not a string).
19
+ *
20
+ * @param {string} dir
21
+ * @param {string} name
22
+ * @param {string} [ext] e.g. '.json'; '' for a folder
23
+ * @returns {string}
24
+ * @throws {Error} statusCode 404 - to a caller, a name like that names nothing
25
+ */
26
+ export function fileIn(dir, name, ext = '') {
27
+ const root = path.resolve(dir);
28
+ const ok = typeof name === 'string' && name !== '' && name !== '.' && name !== '..'
29
+ && !/[\\/\0]/.test(name);
30
+ const file = ok ? path.join(root, `${name}${ext}`) : null;
31
+ if (!file || path.dirname(file) !== root) {
32
+ throw Object.assign(new Error(`Not found: ${String(name).slice(0, 80)}`), {statusCode: 404, code: 'ENOENT'});
33
+ }
34
+ return file;
35
+ }
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * Anyone who can read the site's JWT_SECRET (shell access, or code running in
26
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;
27
+ * plugin or editing one, unless the site is signed-only - services/codePolicy.js,
28
+ * which hosted sites are) can mint either;
28
29
  * that is true of every session. The session is the site's full top role for
29
30
  * an hour (the Manager can already act as it), with no `sid` to revoke it.
30
31
  *
@@ -37,7 +37,7 @@ const PAGES_DIR = path.join(config.content.contentDir, 'pages');
37
37
  function versionsDirForPath(urlPath) {
38
38
  const normalised = urlPath === '/' || !urlPath ? '_index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
39
39
  const resolved = path.resolve(path.join(VERSIONS_DIR, normalised));
40
- if (!resolved.startsWith(path.resolve(VERSIONS_DIR))) {
40
+ if (resolved !== path.resolve(VERSIONS_DIR) && !resolved.startsWith(path.resolve(VERSIONS_DIR) + path.sep)) {
41
41
  throw new Error('Invalid path: outside versions directory');
42
42
  }
43
43
  return resolved;
@@ -34,6 +34,7 @@ import {checkVisibility} from '../middleware/auth.js';
34
34
  import {getEffectiveLevel} from './userRoles.js';
35
35
  import {executePipeline} from './viewPipeline.js';
36
36
  import * as cache from './cache/index.js';
37
+ import {fileIn} from './safePath.js';
37
38
 
38
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
40
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -122,7 +123,7 @@ export async function ensureViewsDir() {
122
123
  * @throws {Error} If the view file does not exist or cannot be parsed.
123
124
  */
124
125
  export async function readView(slug) {
125
- const file = path.join(VIEWS_DIR, `${slug}.json`);
126
+ const file = fileIn(VIEWS_DIR, slug, '.json');
126
127
  return JSON.parse(await fs.readFile(file, 'utf8'));
127
128
  }
128
129
 
@@ -135,7 +136,7 @@ export async function readView(slug) {
135
136
  */
136
137
  export async function writeView(slug, view) {
137
138
  await ensureViewsDir();
138
- const file = path.join(VIEWS_DIR, `${slug}.json`);
139
+ const file = fileIn(VIEWS_DIR, slug, '.json');
139
140
  await fs.writeFile(file, JSON.stringify(view, null, 4) + '\n', 'utf8');
140
141
  await cache.invalidateTags([`view:${slug}`]);
141
142
  }
@@ -368,7 +369,7 @@ export async function deleteView(slug) {
368
369
  throw new Error(`View "${slug}" not found`);
369
370
  }
370
371
  try {
371
- await fs.unlink(path.join(VIEWS_DIR, `${slug}.json`));
372
+ await fs.unlink(fileIn(VIEWS_DIR, slug, '.json'));
372
373
  } catch {
373
374
  throw new Error(`View "${slug}" not found`);
374
375
  }