domma-cms 1.8.13 → 1.8.15
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 +11 -0
- package/CLAUDE.md +29 -1
- package/admin/js/api.js +1 -1
- package/admin/js/views/plugin-code.js +3 -3
- package/admin/js/views/plugins.js +8 -8
- package/docs/DommaFeatures.md +2 -1
- package/docs/configuration.md +2 -1
- package/package.json +1 -1
- package/server/config.js +3 -2
- package/server/env.js +12 -0
- package/server/routes/api/collections.js +9 -0
- package/server/routes/api/forms.js +9 -4
- package/server/routes/api/plugin-marketplace.js +1 -2
- package/server/routes/api/plugins.js +25 -3
- package/server/server.js +1 -1
- package/server/services/actions.js +5 -2
- package/server/services/adapters/FileAdapter.js +2 -1
- package/server/services/adapters/MongoAdapter.js +22 -6
- package/server/services/codePolicy.js +111 -0
- package/server/services/collections.js +2 -1
- package/server/services/components.js +3 -2
- package/server/services/connectionManager.js +8 -0
- package/server/services/content.js +1 -1
- package/server/services/contextMenus.js +2 -1
- package/server/services/filterEngine.js +3 -1
- package/server/services/forms.js +4 -3
- package/server/services/menus.js +2 -1
- package/server/services/mongoQueryGuard.js +193 -0
- package/server/services/outbound.js +394 -0
- package/server/services/pluginFiles.js +8 -1
- package/server/services/pluginInstaller.js +16 -13
- package/server/services/pluginScaffold.js +3 -0
- package/server/services/safePath.js +35 -0
- package/server/services/staffAccess.js +2 -1
- package/server/services/versions.js +1 -1
- package/server/services/views.js +17 -4
- package/server/services/wpImport/fetch.js +12 -181
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.15] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- **Fixed: a view or filter on a MongoDB collection could run JavaScript inside the database server.** A saved view, the view editor's live results, a `[view]` or `[collection]` on a page, a collection filter, an `/api/v1` query or an API Builder endpoint could carry `$where`, `$function`, `$accumulator` and the like - hidden at any depth - and MongoDB would run it. Every query is now checked in one place before MongoDB sees it, and refused with a plain message if it holds anything that runs code, writes (`$out`, `$merge`), reads beyond the site's own collections (`$unionWith`, `$graphLookup`, a `$lookup` into another database or something that is not one of the site's collections) or uses a name like `__proto__`. A view is checked when it is saved, too. Ordinary filters and views work as before; a `$lookup` that names a collection by its plain name (`people` rather than `cms_people`) now works on MongoDB as it already did on files. An entry id sent as anything but text (`{"$ne": ""}` in an Action's run request, say) no longer picks whichever MongoDB entry matched first.
|
|
6
|
+
- **Fixed: webhooks could call addresses inside the server's own network.** An Action's webhook step and a form's webhook would send a request anywhere - `http://127.0.0.1:…`, a private LAN address, or the cloud metadata address. They now reach public addresses only: the name is looked up first and every address it gives must be public and not this server's own, the request goes to the address that was checked (so a second lookup cannot swap in an internal one), redirects into a private address are refused, and only ports 80 and 443 are used. A webhook's own headers (an API key, say) are not passed on when it redirects to another site, and it cannot set `Host`. A form's webhook that answers with an error is now reported like any other failed form action. Self-hosted sites that send webhooks to their own network can allow it in `config/server.json` with `outboundAllowPrivate` - `true`, or a list of host names, addresses and ranges. It has no effect on a site its host runs signed-only (every site Domma CMS Manager hosts).
|
|
7
|
+
|
|
8
|
+
## [1.8.14] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
3
14
|
## [1.8.13] - 2026-10-16
|
|
4
15
|
|
|
5
16
|
- **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
|
@@ -125,6 +125,17 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
|
|
|
125
125
|
- System preset collections (`presetCollections.js` - roles, user profiles, projects, notifications, api tokens/endpoints) are **always** file-based whatever their schema says, and cannot be moved (`isFixedToFile()`)
|
|
126
126
|
- **Exception - the user-data presets** (`USER_DATA_PRESET_SLUGS`: `notes-notes`, `todo-items`, `contacts-contacts`, `contacts-groups`): `resolveUserDataPreset()` in `adapterRegistry.js` uses MongoDB only when the schema declares it (notes and todo do by default, contacts declare file), the named connection is connected, AND their `data.json` holds no rows; otherwise files. Rows in files (or in both stores) keep it on files and are logged with counts (`getStorageConflicts()`), because switching would show users an empty store - the Storage move (id-preserving) settles it. No connection configured = files, silently (MongoDB is Pro); configured but down = files + the storage-down notice. `seedAll()` keeps these presets' existing `storage` block, so a move survives a restart
|
|
127
127
|
- Absence of `config/connections.json` = file mode (server.js wraps Mongo init in try/catch)
|
|
128
|
+
- **User-shaped queries reach MongoDB only through `mongoQueryGuard.js`**: `toSafeMongoQuery()` (MongoAdapter.list - so every
|
|
129
|
+
filter source: `[collection]`, `/api/v1` params, API Builder) and `toSafeMongoPipeline()` (views' native aggregation;
|
|
130
|
+
`validateStages()` runs the same check on save). Refused at any depth, dotted keys a part at a time: code
|
|
131
|
+
(`$where`, `$function`, `$accumulator`, BSON Code), writes/other data (`$out`, `$merge`, `$unionWith`,
|
|
132
|
+
`$graphLookup`, server-state stages, a `$lookup` not into a site `cms_<slug>` collection) and `__proto__`/`constructor`/
|
|
133
|
+
`prototype`. A new path that hands user input to `find`/`aggregate` must go through one of the two
|
|
134
|
+
- **Outbound requests to user-chosen URLs go through `services/outbound.js` `safeFetch()`** (Actions' webhook step,
|
|
135
|
+
form webhooks; plugins fetching a user's URL should too) - never bare `fetch`. Public addresses only after DNS, the
|
|
136
|
+
connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
|
|
137
|
+
`server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
|
|
138
|
+
ignored when `codePolicy.signedOnly()`. `wpImport/fetch.js` shares its address rules and pinned request
|
|
128
139
|
|
|
129
140
|
## Roles & Permissions
|
|
130
141
|
|
|
@@ -175,6 +186,23 @@ upgrade extracts to `.<slug>.incoming` and validates it with `validatePluginDir(
|
|
|
175
186
|
copy is touched, carries `data/` and `.entitlement.json` across, and restores the old directory on
|
|
176
187
|
any failure - "neither version installed" is the one state it must never produce. Versions compare
|
|
177
188
|
numerically (1.10.0 > 1.2.0). Unsigned installs and downgrades each need their own explicit flag.
|
|
189
|
+
|
|
190
|
+
**Signed-only sites (hosted) - `server/services/codePolicy.js`.** `signedOnly()` is true when the environment has
|
|
191
|
+
`DOMMA_SIGNED_ONLY=1` (the Manager sets it on sites it forks, after the site's .env), when server.json had
|
|
192
|
+
`requireSignedPlugins: true` at boot, or when it has it now. On such a site the hosted owner is level 0, and
|
|
193
|
+
code they run could read JWT_SECRET and forge a staff session, so the SERVICES refuse (403, `code:
|
|
194
|
+
'signed-only'`): unsigned installs (`installPlugin`, checked before the "has to be confirmed" refusal),
|
|
195
|
+
`scaffoldPlugin`, `writePluginFile`/`deletePluginFile`; the routes also refuse an unsigned upload before the
|
|
196
|
+
409 upgrade question, read licensed source as level 0, and save risky MongoDB options (`riskyConnectionOptions`;
|
|
197
|
+
`connectionManager` skips such a connection there). `GET /api/plugins/_policy` tells the screens (no New
|
|
198
|
+
plugin; the code editor is read only). Nothing may write server.json from the admin. A new door that lets the
|
|
199
|
+
admin put code or a file into plugins/ or server/ must call `assertMayChangeCode()`. `.env` loads through `server/env.js`
|
|
200
|
+
(`dotenv.config({override: false})`, never `dotenv/config`, which reads `DOTENV_CONFIG_OVERRIDE` from the environment).
|
|
201
|
+
|
|
202
|
+
**File stores take plain names only - `server/services/safePath.js` `fileIn(dir, name, ext)`.** Fastify decodes
|
|
203
|
+
route params, so `/api/forms/..%2F..%2Fconfig%2Fserver` arrived as `../../config/server` and the forms PUT
|
|
204
|
+
rewrote config/server.json. forms, views, menus, context menus, components, collections (+ FileAdapter) and
|
|
205
|
+
`getConfig`/`saveConfig` build their paths through `fileIn`; a new store keyed by a slug must too.
|
|
178
206
|
The multipart consent flags are read **after** the file is drained, because a field the browser wrote
|
|
179
207
|
after the file part is not on `upload.fields` before then.
|
|
180
208
|
|
|
@@ -350,7 +378,7 @@ restarting) mid-run - keep that file's name and meaning.
|
|
|
350
378
|
- **The XML reader is its own** (`xml.js`): any DOCTYPE or ENTITY is refused, only the predefined and numeric
|
|
351
379
|
entities decode - do not swap in a parser that expands entities. Zips (`zip.js`) are read by offset with
|
|
352
380
|
zip-slip names dropped and ratio/size caps; nothing is ever extracted under its own name.
|
|
353
|
-
- **Media downloads go through `fetch.js` only
|
|
381
|
+
- **Media downloads go through `fetch.js` only** (address rules and pinned request shared with `services/outbound.js`): the export's own hosts, every resolved address public, the
|
|
354
382
|
connection pinned to the checked address, never the server's own interface addresses, ports 80/443 only, no
|
|
355
383
|
redirect off-host; a non-image/PDF body that sniffs as HTML or script is refused (it would be served from /media).
|
|
356
384
|
`WP_IMPORT_ALLOW_PRIVATE_HOSTS=1` (ignored when `NODE_ENV=production`) is the tests' and local browser checks' way
|
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
|
|
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=
|
|
9
|
+
</div>`,T=i=>String(i??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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
|
|
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
|
|
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>`,
|
|
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
|
|
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
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function be(T){const m=document.createElement("template");return m.innerHTML=T.trim(),m.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(T){v.reset();const m=T.get(0).querySelector(".plx")||T.get(0),k=()=>qe.test(location.hash)&&m.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":"",e.staff?"your host's staff can still use it":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),h=M.observable({available:null,site:null,items:[]}),he=M.observable(!0),ne=M.observable(""),b=M.observable("");let fe="all";try{fe=ot.includes(S.get(pe))?S.get(pe):"all"}catch{}const N=M.observable(fe),V=M.observable(""),j=M.observable("all"),ve=M.observable("");v.add(M.effect(()=>{const e=N.value;try{S.set(pe,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:N.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>mt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(e)==="update").length),Pe=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:ve.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(m.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{he.value=!1}}async function ge(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ge()]),st(yt)};async function B(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||b.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.plugins.update(e.name,{enabled:t,cascade:i});b.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let i;try{i=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ht(c.body.plan);if(b.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;b.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(i,{onStatus:c=>{b.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(i){E.toast(`Not switched ${n}: ${i.message||i}`,{type:"error"})}finally{b.value="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${d(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await x.marketplace.install(e.slug,e.version,{update:t}),i=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${i} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await P()}}function ye(e){e?.buyUrl&&(window.open(e.buyUrl,"_blank","noopener"),E.toast(`Manage my site opened in a new tab. We email you a sign-in link; ${e.displayName||e.slug} is ticked ready once you are in. Install it here when it is paid for.`,{type:"info",duration:9e3}))}async function we(e,t){const n=W(e)?.tier==="paid"?` <p class="text-muted">It is a paid Tool: your site's manager is told, and if you pay for it on its own it comes off your monthly bill (the rest of this month is credited).</p>`:"";if(await E.confirm(`<p>Uninstall ${d(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>${n}`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{const i=(await x.marketplace.uninstall(e))?.release?.removed?" It is off your bill.":"";E.toast(`${t||e} uninstalled.${i} Restart the server to apply.`,{type:"success",duration:6e3})}catch(l){E.toast(`Uninstall failed: ${l.message||l}`,{type:"error"})}k()&&await P()}}async function xe(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ke=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=m.querySelector(".plx-file");async function _e(e){if(b.peek())return;b.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await x.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(b.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${d(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${d(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}b.value=`Installing ${e.name}\u2026`}}finally{b.value="",k()&&await P()}}const $e=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",$e),v.add(()=>z.removeEventListener("change",$e));const G=new Set;let U=null,Ee=!1;function Se({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,O=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(O)return;O=!0;const q=await E.confirm("Discard the changes you have not saved?");if(O=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),O=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",bt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const A=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${A}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${H}<input id="${A}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${A}" class="form-input" ${K}>${s.options.map(Ae=>`<option value="${d(Ae.value)}">${d(Ae.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${A}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${A}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${A}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=be(`
|
|
37
|
+
</li>`,d=T=>String(T??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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
|
|
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(
|
|
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
|
|
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))}}};
|
package/docs/DommaFeatures.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -30,7 +30,8 @@ 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`,
|
|
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
|
+
| `outboundAllowPrivate` | boolean \| string[] | `false` | Webhooks (an Action's webhook step, a form's webhook) reach public addresses only: the name is looked up and every address must be public and not the server's own, the request is pinned to that address, redirects into private addresses are refused, and only ports 80 and 443 are used. A self-hosted site that sends webhooks inside its own network sets `true` to lift those rules for every request, or a list of host names, addresses and CIDR ranges (`["n8n.lan", "192.168.10.0/24", "fd00::/8"]`) to lift them for those only - a name passes if it is listed, or if every address it resolves to is. Ignored on a signed-only site (`requireSignedPlugins` / `DOMMA_SIGNED_ONLY=1`), so a hosted site's admin cannot open the host's network. Read fresh on every request. See `server/services/outbound.js` |
|
|
34
35
|
|
|
35
36
|
### Plugin toggles restart the server
|
|
36
37
|
|
package/package.json
CHANGED
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(
|
|
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(
|
|
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);
|