domma-cms 0.116.8 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ <div class="view-header">
2
+ <h1><span data-icon="code"></span> API &middot; Content API</h1>
3
+ <a href="#/api-reference" class="btn btn-ghost btn-sm"><span data-icon="arrow-left"></span> API overview</a>
4
+ </div>
5
+ <style>
6
+ .method-badge {
7
+ display: inline-block;
8
+ font-size: 11px;
9
+ font-weight: 700;
10
+ letter-spacing: 0.5px;
11
+ padding: 2px 7px;
12
+ border-radius: 4px;
13
+ font-family: var(--dm-font-mono, monospace);
14
+ margin-right: 6px;
15
+ vertical-align: middle;
16
+ }
17
+
18
+ .method-get {
19
+ background: color-mix(in srgb, var(--dm-success) 15%, transparent);
20
+ color: var(--dm-success);
21
+ }
22
+
23
+ .method-post {
24
+ background: color-mix(in srgb, var(--dm-primary) 15%, transparent);
25
+ color: var(--dm-primary);
26
+ }
27
+
28
+ .method-put {
29
+ background: color-mix(in srgb, var(--dm-warning) 15%, transparent);
30
+ color: var(--dm-warning-dark);
31
+ }
32
+
33
+ .method-patch {
34
+ background: color-mix(in srgb, var(--dm-info) 15%, transparent);
35
+ color: var(--dm-info);
36
+ }
37
+
38
+ .method-delete {
39
+ background: color-mix(in srgb, var(--dm-danger) 15%, transparent);
40
+ color: var(--dm-danger);
41
+ }
42
+
43
+ .endpoint-path {
44
+ font-family: var(--dm-font-mono, monospace);
45
+ font-size: 14px;
46
+ }
47
+
48
+ .auth-note {
49
+ font-size: 12px;
50
+ color: var(--dm-text-muted, #6b7280);
51
+ margin: 4px 0 12px;
52
+ }
53
+
54
+ .auth-note code {
55
+ font-size: 11px;
56
+ }
57
+
58
+ .docs-body h3 {
59
+ margin-top: 24px;
60
+ margin-bottom: 8px;
61
+ }
62
+
63
+ .docs-body h3:first-child {
64
+ margin-top: 0;
65
+ }
66
+ </style>
67
+
68
+
69
+ <div class="row">
70
+ <div class="col-12">
71
+ <div class="docs-body">
72
+ <p>The Content API is read-only, headless access to what the site publishes - pages, menus, the site settings
73
+ public pages already show, and search - as JSON at <code>/api/content/v1</code>. Where the
74
+ <a href="#/docs/api/external">External API</a> exposes collection <em>data</em>, this exposes
75
+ <em>content</em>.</p>
76
+ <p><strong>It is off until you turn it on</strong> at <a href="#/content-api">System &gt; Content API</a>
77
+ (saved in <code>config/content-api.json</code>); until then every address answers <code>404</code>.</p>
78
+
79
+ <h2>Endpoints</h2>
80
+ <table class="table table-sm">
81
+ <thead><tr><th>Method</th><th>Path</th><th>Returns</th></tr></thead>
82
+ <tbody>
83
+ <tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/pages</code></td><td><code>{ pages, total, page, limit }</code> - summaries, no bodies</td></tr>
84
+ <tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/pages/&lt;path&gt;</code></td><td>One page. <code>/pages/</code> is the home page; a directory path serves its index</td></tr>
85
+ <tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/menus/:location</code></td><td><code>{ location, slug, name, items }</code></td></tr>
86
+ <tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/site</code></td><td>Title, brand, footer, social links, SEO defaults, theme</td></tr>
87
+ <tr><td><span class="method-badge method-get">GET</span></td><td><code>/api/content/v1/search?q=</code></td><td><code>{ results: [{ urlPath, title, description, tags, snippet, score }] }</code></td></tr>
88
+ </tbody>
89
+ </table>
90
+
91
+ <h2>Which pages</h2>
92
+ <p>A page the API serves is exactly a page the site would serve the same caller - the two make one decision.</p>
93
+ <ul>
94
+ <li>Only published pages. Drafts, scheduled and status-less pages answer <code>404</code>.</li>
95
+ <li>Visibility is the site's: a role-gated page is <code>403</code> to a caller who could not open it.</li>
96
+ <li>A page in a switched-off project is <code>404</code>.</li>
97
+ <li>Front matter <code>api: false</code> keeps a page off the API - not served, listed or searched. It still shows on the site.</li>
98
+ </ul>
99
+
100
+ <h3>Page list parameters</h3>
101
+ <table class="table table-sm">
102
+ <thead><tr><th>Parameter</th><th>Default</th><th>Description</th></tr></thead>
103
+ <tbody>
104
+ <tr><td><code>page</code></td><td><code>1</code></td><td>Page number</td></tr>
105
+ <tr><td><code>limit</code></td><td><code>50</code></td><td>Page size, capped at the site's page size limit (default 200). <code>0</code> means the default</td></tr>
106
+ <tr><td><code>prefix</code></td><td>-</td><td>Only pages whose path starts with this, e.g. <code>/services</code></td></tr>
107
+ <tr><td><code>tag</code> / <code>category</code></td><td>-</td><td>Only pages with this tag, or in this category</td></tr>
108
+ <tr><td><code>project</code></td><td>-</td><td>Only pages in this project</td></tr>
109
+ <tr><td><code>sort</code> / <code>order</code></td><td>site order</td><td><code>title</code>, <code>updatedAt</code> or <code>createdAt</code>; <code>order=desc</code> reverses</td></tr>
110
+ </tbody>
111
+ </table>
112
+ <p>The list can be switched off on its own (single pages stay), so a caller then has to know a page's path.</p>
113
+
114
+ <h3>A page</h3>
115
+ <pre class="code-block"><code class="language-json">GET /api/content/v1/pages/about?format=html (html | markdown | both)
116
+
117
+ {
118
+ "urlPath": "/about", "title": "About", "description": "…", "tags": ["company"],
119
+ "category": "general", "layout": "default", "createdAt": "…", "updatedAt": "…",
120
+ "seo": { "title": "…", "description": "…", "image": "…" },
121
+ "html": "&lt;section&gt;…&lt;/section&gt;",
122
+ "requires": { "domma": true, "components": ["tabs", "effects"] },
123
+ "perViewer": false
124
+ }</code></pre>
125
+ <ul>
126
+ <li><code>html</code> is the page body (no navbar or footer), shortcodes already rendered. <code>markdown</code> is the source, shortcodes included. A format the site does not offer answers <code>400</code>.</li>
127
+ <li><code>requires</code> names the Domma behaviour the HTML relies on - tabs, accordions, carousels, countdowns, tooltips, reveals, effects, forms and custom components. Without Domma's CSS and JS those parts do nothing.</li>
128
+ <li>Only the front matter keys shown are returned - never your own custom keys.</li>
129
+ <li><code>perViewer: true</code> - the body was rendered for this caller and must not be shared between users.</li>
130
+ <li>Drafts: in role mode only, <code>?preview=1</code> from someone with <code>pages.read</code> returns unpublished pages, never cached.</li>
131
+ </ul>
132
+
133
+ <h3>Menus, site settings and search</h3>
134
+ <ul>
135
+ <li><strong>Menus</strong> - <code>?page=/about</code> resolves page-bound menus as on that page. Items carry only what a navbar needs (<code>type</code>, <code>text</code>, <code>url</code>, <code>icon</code>, <code>target</code>, <code>size</code>, <code>badge</code>, <code>items</code>) and are filtered for the caller. The admin sidebar and page overlays are never served.</li>
136
+ <li><strong>Site</strong> - title, brand, footer (hidden links stay hidden), social links, SEO defaults and theme. Never mail settings, the site URL or anything from the admin.</li>
137
+ <li><strong>Search</strong> - the site's own search. A query shorter than the site's minimum is <code>400</code>; with search off in System &gt; Search it is <code>404</code>.</li>
138
+ </ul>
139
+
140
+ <h2>Access</h2>
141
+ <table class="table table-sm">
142
+ <thead><tr><th>Who may call it</th><th>Credential</th><th>Pages rendered as</th></tr></thead>
143
+ <tbody>
144
+ <tr><td>Public</td><td>None</td><td>An anonymous visitor</td></tr>
145
+ <tr><td>API token (default)</td><td><code>Authorization: Bearer dcms_…</code> - a JWT is refused</td><td>An anonymous visitor</td></tr>
146
+ <tr><td>A role</td><td><code>Authorization: Bearer &lt;access JWT&gt;</code> of that role or more senior. The site's login cookie is never accepted</td><td>That user</td></tr>
147
+ </tbody>
148
+ </table>
149
+ <ul>
150
+ <li>A token reads only the pages of <strong>its own project</strong> - single pages, the list and search. A page in another project is <code>404</code>. Menus and site settings are site-wide, so any valid token may read them.</li>
151
+ <li>Scopes narrow a token: in the token editor, a line of <code>@pages</code>, <code>@menus</code>, <code>@site</code> or <code>@search</code> (read-only, no verbs). A token with no scopes covers everything in its project; one whose scopes name only collections gets no content.</li>
152
+ <li>A role the site does not have is refused when you save the settings.</li>
153
+ </ul>
154
+
155
+ <h2>Responses</h2>
156
+ <table class="table table-sm">
157
+ <thead><tr><th>Case</th><th>Status</th><th>Cache-Control</th></tr></thead>
158
+ <tbody>
159
+ <tr><td>Public</td><td><code>200</code></td><td><code>public, max-age=60</code> + ETag</td></tr>
160
+ <tr><td>API token</td><td><code>200</code></td><td><code>private, no-cache</code></td></tr>
161
+ <tr><td>Role, or a draft</td><td><code>200</code></td><td><code>no-store</code></td></tr>
162
+ <tr><td>Off, missing, unpublished, disabled project, <code>api: false</code></td><td><code>404</code></td><td><code>no-store</code></td></tr>
163
+ <tr><td>No or invalid credential</td><td><code>401</code></td><td><code>no-store</code></td></tr>
164
+ <tr><td>Role too junior, scope or visibility refuses</td><td><code>403</code></td><td><code>no-store</code></td></tr>
165
+ <tr><td>Format not offered, query too short</td><td><code>400</code></td><td><code>no-store</code></td></tr>
166
+ </tbody>
167
+ </table>
168
+
169
+ <h2>Calling it</h2>
170
+ <pre class="code-block"><code class="language-bash"># Public mode
171
+ curl 'https://example.com/api/content/v1/pages?prefix=/blog&amp;limit=10'
172
+
173
+ # Token mode
174
+ curl -H 'Authorization: Bearer dcms_0123...cdef' \
175
+ 'https://example.com/api/content/v1/pages/about?format=both'</code></pre>
176
+ <p>A browser on <strong>another domain</strong> can call it only when that origin is allowed by
177
+ <code>cors.origin</code> in <code>config/server.json</code>. With a token or a role the browser sends a
178
+ preflight first; allowed origins are answered automatically. Calls from a server are unaffected.</p>
179
+ <p>Try any page as an anonymous caller would get it - before switching the API on - on the
180
+ <a href="#/content-api">Content API</a> screen's <strong>Try it</strong> tab.</p>
181
+ </div>
182
+ </div>
183
+ </div>
@@ -150,7 +150,8 @@
150
150
  later.</li>
151
151
  <li>Optionally add <strong>scopes</strong>, one line per collection: <code>jobs: read</code>,
152
152
  <code>enquiries: create, read</code>. No lines means every collection in the project; a collection with no
153
- verbs means all four.</li>
153
+ verbs means all four. A line of <code>@pages</code>, <code>@menus</code>, <code>@site</code> or
154
+ <code>@search</code> is a <a href="#/docs/api/content">Content API</a> scope - read-only, no verbs.</li>
154
155
  <li>Optionally set <strong>expires</strong>. From then on every call with it is refused; the sidebar badge warns
155
156
  14 days ahead and turns red once one has expired.</li>
156
157
  <li>Save and <strong>copy the token now</strong> - <code>dcms_</code> followed by 64 hex characters. It is never
@@ -30,7 +30,7 @@
30
30
  <li><strong>Design</strong> - built-in themes, menus, right-click menus and a visitor theme switcher.</li>
31
31
  <li><strong>Data</strong> - collections on file storage, saved views, a form builder, and public collection
32
32
  displays.</li>
33
- <li><strong>API</strong> - the <code>/api/v1</code> collection API, API tokens and API Builder endpoints.</li>
33
+ <li><strong>API</strong> - the <code>/api/v1</code> collection API, the <code>/api/content/v1</code> Content API, API tokens and API Builder endpoints.</li>
34
34
  <li><strong>Admin</strong> - dashboard, notifications, in-admin docs, projects, roles and permissions.</li>
35
35
  <li><strong>Built-in Tools</strong> - Notes, Todo, Analytics, Contacts and SEO. Each can be switched off in
36
36
  the <a href="#/plugins">Marketplace</a> under "Built-in Tools".</li>
@@ -1,4 +1,4 @@
1
- import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as le,openMenuAt as re,sidebarCount as Me,viewLifetime as Le}from"../lib/tool-kit.js";import{FILTERS as Ae,arrangeTokens as $e,checkToken as Ne,countStates as Oe,scopesToText as Ie,toLocalInput as qe,tokenRow as Pe,tokenState as ce}from"../lib/api-tokens-arrange.js";const de=/^#\/api-tokens\/?(\?.*)?$/,Re="#/api-tokens",R="api-tokens.draft",H="api-tokens.filter",De=`
1
+ import{api as g}from"../api.js";import{addBannerAction as Te}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as le,openMenuAt as re,sidebarCount as Me,viewLifetime as Ae}from"../lib/tool-kit.js";import{FILTERS as Le,arrangeTokens as $e,checkToken as Ie,countStates as Oe,scopesToText as Ne,toLocalInput as Pe,tokenRow as qe,tokenState as ce}from"../lib/api-tokens-arrange.js";const de=/^#\/api-tokens\/?(\?.*)?$/,Re="#/api-tokens",R="api-tokens.draft",H="api-tokens.filter",De=`
2
2
  <div class="atx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="atx-icon" aria-hidden="true"><span data-icon="key" data-icon-size="16"></span></span>
4
4
  <span class="atx-main"><strong>{{name}}</strong><small><code>{{hint}}</code> \xB7 {{meta}}</small></span>
@@ -8,7 +8,7 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
8
8
  </span>
9
9
  <span class="atx-used" data-bind-text="used"></span>
10
10
  <button type="button" class="atx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
- </div>`,A=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(c){const d=document.createElement("template");return d.innerHTML=c.trim(),d.content.firstElementChild}async function ue(c){try{return await navigator.clipboard.writeText(c),!0}catch{return!1}}const l=Le(de);export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(c){l.reset();const d=c.get(0).querySelector(".atx")||c.get(0),$=()=>de.test(location.hash)&&d.isConnected,w=M.observable([]),j=M.observable([]),D=M.observable(!0),_=M.observable("");let U="all";try{U=Ae.includes(S.get(H))?S.get(H):"all"}catch{}const r=M.observable(U),b=M.observable(""),Y=M.observable("");l.add(M.effect(()=>{const e=r.value;try{S.set(H,e)}catch{}}));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=$e(w.value,{filter:r.value,project:b.value,query:Y.value},e).map(t=>Pe(t,e))}));const V=M.computed(()=>Oe(w.value)),fe=M.computed(()=>w.value.length?"No tokens match.":"No API tokens yet - New token in the banner makes one."),T=e=>w.peek().find(t=>t.id===e);async function J(){try{const[e,t]=await Promise.all([g.apiTokens.list(),g.projects.list().catch(()=>[])]);if(!$())return;w.value=Array.isArray(e)?e:[],j.value=Array.isArray(t)?t:[],_.value="",ke()}catch(e){_.value=`Could not load the tokens: ${e.message||e}`}finally{D.value=!1}}const N=async()=>{Me(Re),$()&&await J()};async function W(e,t){if(!(!t&&!await E.confirm(`Switch off "${A(e.name)}"? Callers using it are refused until it is switched back on.`)))try{await g.apiTokens.update(e.id,{enabled:t}),E.toast(`"${e.name}" ${t?"switched on":"switched off"}.`,{type:"success"}),await N()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function me(e){if(await E.confirm(`Revoke "${A(e.name)}"? Anything using it loses access at once. This cannot be undone - a new token would have a different value.`))try{await g.apiTokens.revoke(e.id),E.toast(`Revoked "${e.name}".`,{type:"success"}),await N()}catch(t){E.toast(`Not revoked: ${t.message||t}`,{type:"error"})}}let h=null,y=null;const C=new Set;let ve=!1;function G(e,t,a){const o=e.close.bind(e);let s=!1,u=!1;return e.close=async v=>{if(!s&&v!==!0&&t()){if(u)return;u=!0;const O=await E.confirm(a);if(u=!1,!O)return}return s=!0,o()},()=>{s=!0}}function L(e=null,t=null){const a=e?T(e):null;if(e&&!a){E.toast("That token is no longer there.",{type:"warning"});return}const o=!a,s=j.peek();if(o&&!s.length){E.toast("Make a project first - a token works inside one.",{type:"warning"});return}const u={name:a?.name??"",project:a?.project??(b.peek()||s[0]?.slug||""),expires:qe(a?.expiresAt),scopes:Ie(a?.scopes),enabled:a?a.enabled!==!1:!0,...t||{}},v=Object.fromEntries(Object.entries(u).map(([n,i])=>[n,M.observable(i)])),O=n=>JSON.stringify(Object.fromEntries(Object.entries(n).map(([i,z])=>[i,typeof z=="boolean"?z:String(z??"")]))),ee=()=>Object.fromEntries(Object.keys(v).map(n=>[n,v[n].peek()])),ge=O(u),te=()=>O(ee())!==ge||!!t,q=M.observable(!1),K=M.computed(()=>Ne(Object.fromEntries(Object.keys(v).map(n=>[n,v[n].value])),{isNew:o,projects:j.value.map(n=>n.slug)})),P=n=>M.computed(()=>K.value.errors[n]||""),Ee=a?ce(a):null,ae={...v,saving:q,isNew:o,notNew:!o,nameErr:P("name"),projectErr:P("project"),scopesErr:P("scopes"),expiresErr:P("expires"),cannot:M.computed(()=>q.value||Object.keys(K.value.errors).length>0),saveText:o?"Create token":"Save",fixedProject:a?`${a.project} - fixed for the token's life (revoke and make a new one to move it)`:"",hint:a?`dcms_\u2026${a.tokenHint||"????"}${Ee?.state==="expired"?" - expired: set a later date to bring it back":""}`:""},f=E.slideover({title:o?"New API token":`Edit ${A(a.name)}`,size:"md",position:"right"}),x=pe(`
11
+ </div>`,L=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(c){const d=document.createElement("template");return d.innerHTML=c.trim(),d.content.firstElementChild}async function ue(c){try{return await navigator.clipboard.writeText(c),!0}catch{return!1}}const l=Ae(de);export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(c){l.reset();const d=c.get(0).querySelector(".atx")||c.get(0),$=()=>de.test(location.hash)&&d.isConnected,x=M.observable([]),j=M.observable([]),D=M.observable(!0),_=M.observable("");let U="all";try{U=Le.includes(S.get(H))?S.get(H):"all"}catch{}const r=M.observable(U),b=M.observable(""),Y=M.observable("");l.add(M.effect(()=>{const e=r.value;try{S.set(H,e)}catch{}}));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=$e(x.value,{filter:r.value,project:b.value,query:Y.value},e).map(t=>qe(t,e))}));const V=M.computed(()=>Oe(x.value)),fe=M.computed(()=>x.value.length?"No tokens match.":"No API tokens yet - New token in the banner makes one."),C=e=>x.peek().find(t=>t.id===e);async function J(){try{const[e,t]=await Promise.all([g.apiTokens.list(),g.projects.list().catch(()=>[])]);if(!$())return;x.value=Array.isArray(e)?e:[],j.value=Array.isArray(t)?t:[],_.value="",ke()}catch(e){_.value=`Could not load the tokens: ${e.message||e}`}finally{D.value=!1}}const O=async()=>{Me(Re),$()&&await J()};async function W(e,t){if(!(!t&&!await E.confirm(`Switch off "${L(e.name)}"? Callers using it are refused until it is switched back on.`)))try{await g.apiTokens.update(e.id,{enabled:t}),E.toast(`"${e.name}" ${t?"switched on":"switched off"}.`,{type:"success"}),await O()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function me(e){if(await E.confirm(`Revoke "${L(e.name)}"? Anything using it loses access at once. This cannot be undone - a new token would have a different value.`))try{await g.apiTokens.revoke(e.id),E.toast(`Revoked "${e.name}".`,{type:"success"}),await O()}catch(t){E.toast(`Not revoked: ${t.message||t}`,{type:"error"})}}let h=null,y=null;const T=new Set;let ve=!1;function G(e,t,a){const o=e.close.bind(e);let s=!1,u=!1;return e.close=async v=>{if(!s&&v!==!0&&t()){if(u)return;u=!0;const N=await E.confirm(a);if(u=!1,!N)return}return s=!0,o()},()=>{s=!0}}function A(e=null,t=null){const a=e?C(e):null;if(e&&!a){E.toast("That token is no longer there.",{type:"warning"});return}const o=!a,s=j.peek();if(o&&!s.length){E.toast("Make a project first - a token works inside one.",{type:"warning"});return}const u={name:a?.name??"",project:a?.project??(b.peek()||s[0]?.slug||""),expires:Pe(a?.expiresAt),scopes:Ne(a?.scopes),enabled:a?a.enabled!==!1:!0,...t||{}},v=Object.fromEntries(Object.entries(u).map(([n,i])=>[n,M.observable(i)])),N=n=>JSON.stringify(Object.fromEntries(Object.entries(n).map(([i,z])=>[i,typeof z=="boolean"?z:String(z??"")]))),ee=()=>Object.fromEntries(Object.keys(v).map(n=>[n,v[n].peek()])),ge=N(u),te=()=>N(ee())!==ge||!!t,P=M.observable(!1),K=M.computed(()=>Ie(Object.fromEntries(Object.keys(v).map(n=>[n,v[n].value])),{isNew:o,projects:j.value.map(n=>n.slug)})),q=n=>M.computed(()=>K.value.errors[n]||""),Ee=a?ce(a):null,ae={...v,saving:P,isNew:o,notNew:!o,nameErr:q("name"),projectErr:q("project"),scopesErr:q("scopes"),expiresErr:q("expires"),cannot:M.computed(()=>P.value||Object.keys(K.value.errors).length>0),saveText:o?"Create token":"Save",fixedProject:a?`${a.project} - fixed for the token's life (revoke and make a new one to move it)`:"",hint:a?`dcms_\u2026${a.tokenHint||"????"}${Ee?.state==="expired"?" - expired: set a later date to bring it back":""}`:""},f=E.slideover({title:o?"New API token":`Edit ${L(a.name)}`,size:"md",position:"right"}),w=pe(`
12
12
  <div class="atx-form">
13
13
  <div>
14
14
  <label class="form-label" for="atx-f-name">Name <span class="text-danger">*</span></label>
@@ -27,8 +27,8 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
27
27
  </div>
28
28
  <div>
29
29
  <label class="form-label" for="atx-f-scopes">Scopes
30
- <span data-help-title="Scopes" data-help="Leave empty and the token may use every collection in its project that allows token access. Otherwise one line per collection: the collection's slug, then a colon and the verbs it may use - create, read, update, delete. No verbs means all four. For example: jobs: read"></span></label>
31
- <textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read&#10;enquiries: create, read" data-model="scopes.value"></textarea>
30
+ <span data-help-title="Scopes" data-help="Leave empty and the token may use every collection in its project that allows token access, and the Content API where it is set to API tokens. Otherwise one line each: a collection's slug, then a colon and the verbs it may use - create, read, update, delete (no verbs means all four), for example jobs: read. For the Content API, a line of @pages, @menus, @site or @search - read-only, no verbs. A token with only collection lines gets no content."></span></label>
31
+ <textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read&#10;enquiries: create, read&#10;@pages" data-model="scopes.value"></textarea>
32
32
  <small class="atx-err" data-bind-hidden="!scopesErr.value" data-bind-text="scopesErr.value"></small>
33
33
  </div>
34
34
  <div>
@@ -49,7 +49,7 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
49
49
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
50
50
  <span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
51
51
  </div>
52
- </div>`),ne=x.querySelector("#atx-f-project");for(const n of s){const i=document.createElement("option");i.value=n.slug,i.textContent=`${n.name||n.slug} (${n.slug})`,ne.appendChild(i)}let B=()=>{};async function oe(){if(ae.cannot.peek())return;q.value=!0;const{payload:n}=K.peek();try{if(o){const i=await g.apiTokens.create(n);B(),f.close(),await N(),be(i?.plaintext,n.name)}else await g.apiTokens.update(a.id,n),E.toast("Saved.",{type:"success"}),B(),f.close(),await N()}catch(i){E.toast(`Not saved: ${i.message||i}`,{type:"error"})}finally{q.value=!1}}f.element.appendChild(x);const je=ie(x),Se=M.applyBindings(ae,x,{methods:{save:oe,cancel:()=>f.close()}});ne.value=v.project.peek();const Te=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),oe())};x.addEventListener("keydown",Te);const se={id:a?.id??null,dirty:te,values:ee};h=se,C.add(f),le(f,()=>{Se.dispose(),je(),C.delete(f),h===se&&(h=null)}),B=G(f,te,"Discard the changes you have not saved?"),I.scan(x),f.open(),x.querySelector("#atx-f-name")?.focus()}function be(e,t){if(!e){E.toast("The token was made, but its value did not come back - revoke it and make another.",{type:"error"});return}let a=!1;const o=E.slideover({title:"Token created",size:"sm",position:"right"}),s=pe(`
52
+ </div>`),ne=w.querySelector("#atx-f-project");for(const n of s){const i=document.createElement("option");i.value=n.slug,i.textContent=`${n.name||n.slug} (${n.slug})`,ne.appendChild(i)}let B=()=>{};async function oe(){if(ae.cannot.peek())return;P.value=!0;const{payload:n}=K.peek();try{if(o){const i=await g.apiTokens.create(n);B(),f.close(),await O(),be(i?.plaintext,n.name)}else await g.apiTokens.update(a.id,n),E.toast("Saved.",{type:"success"}),B(),f.close(),await O()}catch(i){E.toast(`Not saved: ${i.message||i}`,{type:"error"})}finally{P.value=!1}}f.element.appendChild(w);const je=ie(w),Se=M.applyBindings(ae,w,{methods:{save:oe,cancel:()=>f.close()}});ne.value=v.project.peek();const Ce=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),oe())};w.addEventListener("keydown",Ce);const se={id:a?.id??null,dirty:te,values:ee};h=se,T.add(f),le(f,()=>{Se.dispose(),je(),T.delete(f),h===se&&(h=null)}),B=G(f,te,"Discard the changes you have not saved?"),I.scan(w),f.open(),w.querySelector("#atx-f-name")?.focus()}function be(e,t){if(!e){E.toast("The token was made, but its value did not come back - revoke it and make another.",{type:"error"});return}let a=!1;const o=E.slideover({title:"Token created",size:"sm",position:"right"}),s=pe(`
53
53
  <div class="atx-secret">
54
54
  <p class="atx-warn"><strong>Copy it now.</strong> This is the only time it is shown - only a fingerprint is kept. If it is lost, revoke it and make another.</p>
55
55
  <code class="atx-token"></code>
@@ -59,4 +59,4 @@ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-
59
59
  <button type="button" class="btn btn-sm btn-primary atx-copy"><span data-icon="copy" data-icon-size="13"></span> Copy</button>
60
60
  <button type="button" class="btn btn-sm btn-ghost atx-done">Done</button>
61
61
  </div>
62
- </div>`);s.querySelector(".atx-token").textContent=e,s.querySelector(".atx-copy").addEventListener("click",async()=>{await ue(e)?(a=!0,s.querySelector(".copied-note").hidden=!1):E.toast("Copy failed - select the token and copy it by hand.",{type:"error"})}),s.querySelector(".atx-done").addEventListener("click",()=>o.close()),o.element.appendChild(s);const u={copied:()=>a};o.__dmSecret=!0,y=u,C.add(o),le(o,()=>{C.delete(o),y===u&&(y=null),e=null}),G(o,()=>!a,`You have not copied the token for "${A(t)}". Once this closes it can never be shown again. Close anyway?`),I.scan(s),o.open()}function he(e){const t=T(e);if(!t)return[];const{state:a}=ce(t);return[{header:t.name||"Token"},{label:"Edit\u2026",icon:"edit",action:()=>L(t.id)},t.enabled===!1?{label:"Switch on",icon:"play",action:()=>W(t,!0)}:{label:"Switch off\u2026",icon:"pause",disabled:a==="expired",action:()=>W(t,!1)},{type:"divider"},{label:"Copy its ending",icon:"copy",action:async()=>{E.toast(await ue(`dcms_\u2026${t.tokenHint||""}`)?"Copied the ending - the token itself is never shown again.":"Copy failed.",{type:"info"})}},{label:`Show only ${t.project}`,icon:"filter",disabled:b.peek()===t.project,action:()=>{b.value=t.project,ye()}},{type:"divider"},{label:"Revoke\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=d.querySelector(".atx-list");m.innerHTML=De;const k=d.querySelector(".atx-project"),ye=()=>{k&&(k.value=b.peek())};function ke(){if(!k)return;const e=b.peek();for(;k.options.length>1;)k.remove(1);for(const t of j.peek()){const a=document.createElement("option");a.value=t.slug,a.textContent=t.name||t.slug,k.appendChild(a)}k.value=e}const xe=M.applyBindings({tokens:w,projects:j,loading:D,loadError:_,filter:r,project:b,query:Y,rows:F,counts:V,emptyText:fe},d,{methods:{showAll:()=>{r.value="all"},showActive:()=>{r.value="active"},showExpiring:()=>{r.value="expiring"},showExpired:()=>{r.value="expired"},showDisabled:()=>{r.value="disabled"}}});l.add(()=>xe.dispose()),l.add(ie(d,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),l.add(M.effect(()=>{const e=V.value,t=r.peek();t!=="all"&&!D.value&&!e[t]&&(r.value="all")}));const we=E.contextMenu(m,{cursor:"glyph",match:".atx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.id)});l.add(()=>we?.destroy?.());const Q=e=>{const t=e.target.closest(".atx-row");if(!t)return;const a=e.target.closest(".atx-kebab");if(a){re(a);return}L(t.dataset.id)},X=e=>{const t=e.target.closest(".atx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};m.addEventListener("click",Q),m.addEventListener("keydown",X),l.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),Ce(c,{id:"api-tokens-new",icon:"plus",title:"New token",onClick:()=>L()});const Z=e=>{(h?.dirty()||y&&!y.copied())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),l.add(()=>window.removeEventListener("beforeunload",Z)),l.add(()=>{if(h?.dirty())try{S.set(R,{id:h.id,values:h.values(),at:new Date().toISOString()})}catch{}ve=!0,[...C].forEach(e=>{if(!(y&&e.__dmSecret&&!y.copied()))try{e.close(!0)}catch{}})}),I.scan(d),await J(),!$())return;let p=null;try{p=S.get(R)}catch{}if(p?.values){try{S.remove?S.remove(R):S.set(R,null)}catch{}const e=p.id&&T(p.id)?`your changes to "${A(T(p.id).name)}"`:"the new token you started";await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&L(p.id&&T(p.id)?p.id:null,p.values)}}};
62
+ </div>`);s.querySelector(".atx-token").textContent=e,s.querySelector(".atx-copy").addEventListener("click",async()=>{await ue(e)?(a=!0,s.querySelector(".copied-note").hidden=!1):E.toast("Copy failed - select the token and copy it by hand.",{type:"error"})}),s.querySelector(".atx-done").addEventListener("click",()=>o.close()),o.element.appendChild(s);const u={copied:()=>a};o.__dmSecret=!0,y=u,T.add(o),le(o,()=>{T.delete(o),y===u&&(y=null),e=null}),G(o,()=>!a,`You have not copied the token for "${L(t)}". Once this closes it can never be shown again. Close anyway?`),I.scan(s),o.open()}function he(e){const t=C(e);if(!t)return[];const{state:a}=ce(t);return[{header:t.name||"Token"},{label:"Edit\u2026",icon:"edit",action:()=>A(t.id)},t.enabled===!1?{label:"Switch on",icon:"play",action:()=>W(t,!0)}:{label:"Switch off\u2026",icon:"pause",disabled:a==="expired",action:()=>W(t,!1)},{type:"divider"},{label:"Copy its ending",icon:"copy",action:async()=>{E.toast(await ue(`dcms_\u2026${t.tokenHint||""}`)?"Copied the ending - the token itself is never shown again.":"Copy failed.",{type:"info"})}},{label:`Show only ${t.project}`,icon:"filter",disabled:b.peek()===t.project,action:()=>{b.value=t.project,ye()}},{type:"divider"},{label:"Revoke\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=d.querySelector(".atx-list");m.innerHTML=De;const k=d.querySelector(".atx-project"),ye=()=>{k&&(k.value=b.peek())};function ke(){if(!k)return;const e=b.peek();for(;k.options.length>1;)k.remove(1);for(const t of j.peek()){const a=document.createElement("option");a.value=t.slug,a.textContent=t.name||t.slug,k.appendChild(a)}k.value=e}const we=M.applyBindings({tokens:x,projects:j,loading:D,loadError:_,filter:r,project:b,query:Y,rows:F,counts:V,emptyText:fe},d,{methods:{showAll:()=>{r.value="all"},showActive:()=>{r.value="active"},showExpiring:()=>{r.value="expiring"},showExpired:()=>{r.value="expired"},showDisabled:()=>{r.value="disabled"}}});l.add(()=>we.dispose()),l.add(ie(d,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),l.add(M.effect(()=>{const e=V.value,t=r.peek();t!=="all"&&!D.value&&!e[t]&&(r.value="all")}));const xe=E.contextMenu(m,{cursor:"glyph",match:".atx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.id)});l.add(()=>xe?.destroy?.());const Q=e=>{const t=e.target.closest(".atx-row");if(!t)return;const a=e.target.closest(".atx-kebab");if(a){re(a);return}A(t.dataset.id)},X=e=>{const t=e.target.closest(".atx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(t)):e.key==="Enter"&&e.target===t&&A(t.dataset.id))};m.addEventListener("click",Q),m.addEventListener("keydown",X),l.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),Te(c,{id:"api-tokens-new",icon:"plus",title:"New token",onClick:()=>A()});const Z=e=>{(h?.dirty()||y&&!y.copied())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),l.add(()=>window.removeEventListener("beforeunload",Z)),l.add(()=>{if(h?.dirty())try{S.set(R,{id:h.id,values:h.values(),at:new Date().toISOString()})}catch{}ve=!0,[...T].forEach(e=>{if(!(y&&e.__dmSecret&&!y.copied()))try{e.close(!0)}catch{}})}),I.scan(d),await J(),!$())return;let p=null;try{p=S.get(R)}catch{}if(p?.values){try{S.remove?S.remove(R):S.set(R,null)}catch{}const e=p.id&&C(p.id)?`your changes to "${L(C(p.id).name)}"`:"the new token you started";await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&A(p.id&&C(p.id)?p.id:null,p.values)}}};
@@ -0,0 +1,7 @@
1
+ import{apiRequest as m}from"../api.js";import{addBannerAction as le}from"../lib/plugin-chrome.js";import{helpPopovers as we}from"../lib/help-popover.js";import{openMenuAt as ue,sidebarCount as Ee}from"../lib/tool-kit.js";import{SHAPES as Se,whileLoading as Me}from"../lib/loading.js";import{fmtDateTime as xe}from"../lib/dates.js";import{accessOptions as Ce,roleLadder as Te}from"../lib/collection-editor-arrange.js";import{checkForm as Ae,curlExample as Oe,endpointRows as Pe,formFromSettings as g,liveChips as Le,normalisePath as Ie,payloadFromForm as b,previewRow as Re,saveNotes as $e,snapshot as H}from"../lib/content-api-arrange.js";const B=/^\/content-api(\?.*)?$/,je="#/content-api",y="content-api.draft",qe='<span class="cax-chip" data-bind-class="cls">{{text}}</span>',De=`
2
+ <div class="cax-row" data-bind-class="cls" data-path="{{path}}" tabindex="0">
3
+ <span class="cax-method">{{method}}</span>
4
+ <span class="cax-main"><code>{{path}}</code><small>{{label}}</small></span>
5
+ <span class="cax-chip" data-bind-class="chipCls">{{stateText}}</span>
6
+ <button type="button" class="cax-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
7
+ </div>`;let l=null,pe=!1;function Fe(){pe||!window.R?.use||(pe=!0,R.use((c,i,s)=>!(B.test(i?.path||"")&&!B.test(c?.path||""))||!l?.dirty()?s():E.confirm("You have unsaved Content API settings. Leave anyway? They are kept as a draft for your next visit.").then(a=>{a?s():history.replaceState(null,"","#/content-api")})),M.subscribe("router:afterChange",({to:c}={})=>{if(l&&!B.test(c?.path||"")){const i=l;l=null,i.dispose()}}))}function J(c,i){const s=()=>E.toast(`${i} copied.`,{type:"success"}),d=()=>E.toast(c,{type:"info",duration:8e3});navigator.clipboard?.writeText?navigator.clipboard.writeText(c).then(s,d):d()}export const contentApiView={templateUrl:"/admin/js/templates/content-api.html",async onMount(c){if(Fe(),l){const e=l;l=null,e.dispose()}const i=[],s=e=>(i.push(e),e);let d=!1;const a=c.get(0).querySelector(".cax")||c.get(0),w=()=>!d&&a.isConnected,Y=M.observable(!0);let $,j=null,x=[];try{[$,j,x]=await Me(a.querySelector(".dm-loading-slot"),Promise.all([m("/content-api/settings"),m("/content-api/_count").catch(()=>null),m("/collections/roles/entries?limit=1000").then(Te).catch(()=>[])]),Se.rows)}catch(e){E.toast(`Could not load the Content API settings: ${e.message||e}`,{type:"error"});return}if(!a.isConnected)return;const h=M.observable($),W=g($),r={};for(const[e,t]of Object.entries(W))r[e]=M.observable(t);const C=()=>Object.fromEntries(Object.keys(r).map(e=>[e,r[e].peek()])),k=()=>Object.fromEntries(Object.keys(r).map(e=>[e,r[e].value])),T=M.observable(H(W)),A=M.computed(()=>H(k())!==T.value),O=M.observable(!1),V=M.observable(j?{served:j.count}:null),z=M.observable([]);s(M.effect(()=>{z.value=Le(h.value,V.value,x).map(e=>({text:e.text,cls:e.tone?`is-${e.tone}`:""}))}));const q=M.computed(()=>Ae(k(),x).access||""),de=M.computed(()=>$e(k()).join(" ")),G=M.computed(()=>Pe(location.origin,b(k())).map(e=>({...e,stateText:e.state==="on"?"On":e.note||"Off",cls:e.state==="off"?"is-off":"",chipCls:e.state==="on"?"is-ok":""}))),Q=a.querySelector("#cax-access"),P=e=>{Q.replaceChildren(...Ce(x,e).map(t=>new Option(t.label,t.value))),Q.value=e};P(r.access.peek());const D=M.observable("/"),F=M.observable("html"),N=M.observable(!1),_=M.observable(""),X=M.observable(""),Z=M.observable(""),ee=M.observable(""),te=M.observable(""),oe=M.observable(""),K=M.observable(""),ne=M.computed(()=>Oe(location.origin,D.value,b(k()),F.value));let L=0;async function ve(){const e=++L,t=Ie(D.peek());N.value=!0,K.value="";try{const o=await m(`/content-api/preview?path=${encodeURIComponent(t)}&format=${encodeURIComponent(F.peek())}&as=anon`);if(e!==L||!w())return;const n=Re(o);_.value=n.statusText,X.value=`is-${n.tone}`,Z.value=n.requiresText,ee.value=n.why,te.value=n.offeredNote,oe.value=JSON.stringify(o.body,null,2)}catch(o){if(e!==L)return;_.value="",K.value=`Could not fetch the page: ${o.message||o}`}finally{e===L&&(N.value=!1)}}async function U(){if(!O.peek()){if(q.peek()){E.toast(q.peek(),{type:"error"});return}O.value=!0;try{const e=await m("/content-api/settings",{method:"PUT",body:JSON.stringify(b(C()))});if(!w())return;const t=e?.settings||b(C());h.value=t;const o=g(t);for(const[p,f]of Object.entries(o))r[p].value=f;P(o.access),T.value=H(o);try{S.remove?S.remove(y):S.set(y,null)}catch{}Ee(je);const n=await m("/content-api/_count").catch(()=>null);n&&w()&&(V.value={served:n.count}),E.toast(t.enabled?"Saved - the Content API is on.":"Saved - the Content API is off.",{type:"success"})}catch(e){E.toast(`Not saved: ${e.message||e}`,{type:"error"})}finally{O.value=!1}}}function fe(){const e=g(h.peek());for(const[t,o]of Object.entries(e))r[t].value=o;P(e.access)}a.querySelector(".cax-chips").innerHTML=qe;const v=a.querySelector(".cax-endpoints");v.innerHTML=De;const me={...r,loading:Y,dirty:A,saving:O,chips:z,accessErr:q,notesText:de,endpoints:G,tryPath:D,tryFormat:F,running:N,tryStatus:_,tryTone:X,tryRequires:Z,tryWhy:ee,tryOffered:te,tryJson:oe,tryError:K,curl:ne};Y.value=!1;const be=E.tabs(a.querySelector(".cax-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),ye=M.applyBindings(me,a,{methods:{save:()=>U(),discard:()=>fe(),run:()=>ve(),copyCurl:()=>J(ne.peek(),"curl command")}});s(()=>ye.dispose()),s(we(a,{position:"bottom"}));const he=e=>G.peek().find(t=>t.path===e),ke=e=>{const t=he(e.dataset.path);if(!t)return[];const o=b(C()),n=o.access==="public"?`curl '${t.url}'`:`curl -H 'Authorization: Bearer ${o.access==="token"?"dcms_\u2026":"<access JWT>"}' '${t.url}'`,p=[{header:t.path},{label:"Copy address",icon:"copy",action:()=>J(t.url,"Address")},{label:"Copy curl command",icon:"code",action:()=>J(n,"curl command")}],f=b(g(h.peek()));return f.enabled&&f.access==="public"&&t.state==="on"&&p.push({label:"Open in a new tab",icon:"external-link",action:()=>window.open(t.url,"_blank","noopener")}),p},ge=E.contextMenu(v,{cursor:"glyph",match:".cax-row",inherit:!1,density:"compact",items:ke});s(()=>ge?.destroy?.());const se=e=>{const t=e.target.closest(".cax-kebab");t&&ue(t)},ae=e=>{const t=e.target.closest(".cax-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ue(t))};v.addEventListener("click",se),v.addEventListener("keydown",ae),s(()=>{v.removeEventListener("click",se),v.removeEventListener("keydown",ae)}),le(c,{id:"content-api-tokens",icon:"key",title:"API Tokens",onClick:()=>{location.hash="#/api-tokens"}}),le(c,{id:"content-api-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>U()});const ce=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),U())};document.addEventListener("keydown",ce,!0),s(()=>document.removeEventListener("keydown",ce,!0));const re=()=>{if(A.peek())try{S.set(y,{form:C(),base:T.peek(),at:new Date().toISOString()})}catch{}},ie=e=>{A.peek()&&(re(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ie),s(()=>window.removeEventListener("beforeunload",ie)),l={dirty:()=>A.peek(),dispose(){d=!0,re(),i.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a);let u=null;try{u=S.get(y)}catch{}if(u?.form){try{S.remove?S.remove(y):S.set(y,null)}catch{}const e=u.base&&u.base!==T.peek(),t=u.at?` (${xe(u.at)})`:"",o=e?`You left unsaved Content API settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved Content API settings${t}. Restore them?`;if(await E.confirm(o)&&w()){const n={...g(h.peek()),...u.form};for(const[p,f]of Object.entries(n))r[p]&&(r[p].value=f);P(n.access)}}}};
@@ -1 +1 @@
1
- import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,s){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof s=="function"&&s(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const s=o.find("#tutorial-scaffolder-mount").get(0);s&&i(s,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const a=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","tools"],c=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","builder"],n=o=>o.split("-").map(s=>s.charAt(0).toUpperCase()+s.slice(1)).join("");for(const o of a)docViews["usage"+n(o)]=t(`usage-${o}`);for(const o of c)docViews["apiRef"+n(o)]=t(`api-${o}`);
1
+ import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,n){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof n=="function"&&n(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const n=o.find("#tutorial-scaffolder-mount").get(0);n&&i(n,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const c=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","tools"],a=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","content","builder"],s=o=>o.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join("");for(const o of c)docViews["usage"+s(o)]=t(`usage-${o}`);for(const o of a)docViews["apiRef"+s(o)]=t(`api-${o}`);
@@ -1 +1 @@
1
- import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as a}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as d}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as N}from"./component-editor.js";import{myProfileView as T}from"./my-profile.js";import{rolesView as U}from"./roles.js";import{roleEditorView as q}from"./role-editor.js";import{effectsView as z}from"./effects.js";import{searchView as A}from"./search.js";import{notesView as B}from"./notes.js";import{todoView as F}from"./todo.js";import{calculatorView as H}from"../lib/calculator.js";import{analyticsView as I}from"./analytics.js";import{seoView as J}from"./seo.js";import{contactsView as K}from"./contacts.js";import{callNewView as O}from"./call-new.js";import{menusView as Q}from"./menus.js";import{contextMenusView as W}from"./context-menus.js";import{contextMenuEditorView as X}from"./context-menu-editor.js";import{menuEditorView as Y}from"./menu-editor.js";import{menuLocationsView as Z}from"./menu-locations.js";import{projectsView as _}from"./projects.js";import{projectDetailView as $}from"./project-detail.js";import{projectSettingsView as oo}from"./project-settings.js";import{apiTokensView as io}from"./api-tokens.js";import{apiEndpointsView as ro}from"./api-endpoints.js";import{apiEndpointEditorView as to}from"./api-endpoint-editor.js";const eo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:_,projectDetail:$,projectSettings:oo,apiTokens:io,apiEndpoints:ro,apiEndpointEditor:to,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,layouts:p,media:n,login:s,users:f,userEditor:w,plugins:c,pluginCode:V,documentation:a,tutorials:l,apiReference:g,collections:v,collectionEditor:b,collectionEntries:h,forms:y,formEditor:j,formSubmissions:k,viewsList:L,viewEditor:x,viewPreview:M,actionsList:D,actionEditor:G,proDocs:P,blocks:R,blockEditor:S,components:C,componentEditor:N,myProfile:T,roles:U,roleEditor:q,effects:z,search:A,notes:B,todo:F,calculator:H,analytics:I,seo:J,contacts:K,callNew:O,notifications:m,menus:Q,contextMenus:W,contextMenuEditor:X,menuEditor:Y,menuLocations:Z,menusRedirect:eo,pluginGuides:u,pluginGuide:E,...d};
1
+ import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as a}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as d}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as A}from"./components.js";import{componentEditorView as C}from"./component-editor.js";import{myProfileView as N}from"./my-profile.js";import{rolesView as T}from"./roles.js";import{roleEditorView as U}from"./role-editor.js";import{effectsView as q}from"./effects.js";import{searchView as z}from"./search.js";import{contentApiView as B}from"./content-api.js";import{notesView as F}from"./notes.js";import{todoView as H}from"./todo.js";import{calculatorView as I}from"../lib/calculator.js";import{analyticsView as J}from"./analytics.js";import{seoView as K}from"./seo.js";import{contactsView as O}from"./contacts.js";import{callNewView as Q}from"./call-new.js";import{menusView as W}from"./menus.js";import{contextMenusView as X}from"./context-menus.js";import{contextMenuEditorView as Y}from"./context-menu-editor.js";import{menuEditorView as Z}from"./menu-editor.js";import{menuLocationsView as _}from"./menu-locations.js";import{projectsView as $}from"./projects.js";import{projectDetailView as oo}from"./project-detail.js";import{projectSettingsView as io}from"./project-settings.js";import{apiTokensView as ro}from"./api-tokens.js";import{apiEndpointsView as to}from"./api-endpoints.js";import{apiEndpointEditorView as eo}from"./api-endpoint-editor.js";const mo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:$,projectDetail:oo,projectSettings:io,apiTokens:ro,apiEndpoints:to,apiEndpointEditor:eo,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,layouts:p,media:n,login:s,users:f,userEditor:w,plugins:c,pluginCode:V,documentation:a,tutorials:l,apiReference:g,collections:v,collectionEditor:b,collectionEntries:h,forms:y,formEditor:j,formSubmissions:k,viewsList:L,viewEditor:x,viewPreview:M,actionsList:D,actionEditor:G,proDocs:P,blocks:R,blockEditor:S,components:A,componentEditor:C,myProfile:N,roles:T,roleEditor:U,effects:q,search:z,contentApi:B,notes:F,todo:H,calculator:I,analytics:J,seo:K,contacts:O,callNew:Q,notifications:m,menus:W,contextMenus:X,contextMenuEditor:Y,menuEditor:Z,menuLocations:_,menusRedirect:mo,pluginGuides:u,pluginGuide:E,...d};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.116.8",
3
+ "version": "0.117.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -61,7 +61,7 @@ import {getConfig, saveConfig} from '../../config.js';
61
61
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
62
62
  import {ensureFormForCollection} from '../../services/forms.js';
63
63
  import {hooks} from '../../services/hooks.js';
64
- import {scopeAllows, validateToken} from '../../services/apiTokens.js';
64
+ import {authenticateApiToken, scopeAllows} from '../../services/apiTokens.js';
65
65
  import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.js';
66
66
  import {maskConnections, restoreConnections} from '../../services/connectionSecrets.js';
67
67
 
@@ -206,23 +206,16 @@ export async function checkPublicAccess(schema, operation, request, reply) {
206
206
 
207
207
  // Token mode - project-scoped API token, strict: a token is the ONLY
208
208
  // accepted credential here (a JWT never satisfies token mode, and a
209
- // token never satisfies a role mode).
209
+ // token never satisfies a role mode). The check itself is shared with the
210
+ // Content API - authenticateApiToken() in services/apiTokens.js.
210
211
  if (access.access === 'token') {
211
- const match = (request.headers.authorization || '').match(/^Bearer (dcms_[a-f0-9]{64})$/);
212
- if (!match) {
213
- return reply.status(401).send({ error: 'API token required' });
214
- }
215
- const token = await validateToken(match[1]);
216
- if (!token) {
217
- return reply.status(401).send({ error: 'Invalid, disabled or expired API token' });
218
- }
219
- if (token.project !== resolveArtefactProject(schema)) {
220
- return reply.status(403).send({ error: "Token is not valid for this collection's project" });
221
- }
222
- if (!scopeAllows(token.scopes, schema.slug, operation)) {
223
- return reply.status(403).send({ error: 'Token scope does not permit this operation' });
224
- }
225
- request.apiToken = token;
212
+ const auth = await authenticateApiToken(request, {
213
+ project: resolveArtefactProject(schema),
214
+ allows: (scopes) => scopeAllows(scopes, schema.slug, operation),
215
+ projectError: "Token is not valid for this collection's project"
216
+ });
217
+ if (!auth.ok) return reply.status(auth.status).send({error: auth.error});
218
+ request.apiToken = auth.token;
226
219
  return;
227
220
  }
228
221
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Content API - admin routes (System › Content API)
3
+ *
4
+ * GET /api/content-api/settings - settings.read: config/content-api.json, normalised
5
+ * PUT /api/content-api/settings - settings.update: save it (a role the site does not have is refused)
6
+ * GET /api/content-api/_count - settings.read: the sidebar badge (contentApiBadge)
7
+ * GET /api/content-api/preview?path=/about&format=html|markdown|both&as=anon
8
+ * - settings.read: the screen's Try it. Exactly what an
9
+ * anonymous caller of /api/content/v1/pages<path> would
10
+ * get - through the same buildPageResponse() - whether
11
+ * or not the API is switched on yet, so a site can look
12
+ * before it publishes. A format the site does not offer
13
+ * still renders, with `offered: false`.
14
+ *
15
+ * The permission is `settings`, the same as the screen's sidebar entry, so whoever
16
+ * sees the entry can read its badge.
17
+ */
18
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
19
+ import {listPages} from '../../services/content.js';
20
+ import {searchExclusions} from '../../services/search.js';
21
+ import {contentApiBadge, getContentApiSettings, saveContentApiSettings} from '../../services/contentApi.js';
22
+ import {getRoleLevel} from '../../services/roles.js';
23
+ import {buildPageResponse} from './content-public.js';
24
+ import {getBaseUrl} from '../public.js';
25
+
26
+ const FORMAT_VALUES = ['html', 'markdown', 'both'];
27
+ const ANONYMOUS = Object.freeze({mode: 'public', viewer: null, token: null});
28
+
29
+ export async function contentApiRoutes(fastify) {
30
+ const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
31
+ const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
32
+
33
+ fastify.get('/content-api/settings', canRead, async () => getContentApiSettings());
34
+
35
+ fastify.put('/content-api/settings', canUpdate, async (request, reply) => {
36
+ const patch = request.body && typeof request.body === 'object' ? request.body : {};
37
+ // A role name that is not a role here would fail closed to level 0 at
38
+ // request time - safe, but a silent surprise. Say so at save instead.
39
+ const access = typeof patch.access === 'string' ? patch.access.trim() : '';
40
+ if (access && access !== 'public' && access !== 'token' && !Number.isFinite(getRoleLevel(access))) {
41
+ return reply.status(400).send({error: `"${access}" is not a role on this site`});
42
+ }
43
+ return {ok: true, settings: saveContentApiSettings(patch)};
44
+ });
45
+
46
+ fastify.get('/content-api/_count', canRead, async () => {
47
+ const pages = await listPages();
48
+ return contentApiBadge(getContentApiSettings(), pages, await searchExclusions(pages));
49
+ });
50
+
51
+ fastify.get('/content-api/preview', canRead, async (request, reply) => {
52
+ const q = request.query || {};
53
+ const path = String(q.path || '').trim();
54
+ if (!path.startsWith('/')) return reply.status(400).send({error: 'Give a page path starting with /'});
55
+ // Only the anonymous view for now: it is what public and token callers get.
56
+ if (q.as != null && q.as !== 'anon') return reply.status(400).send({error: 'Preview runs as an anonymous caller only (as=anon)'});
57
+ const format = q.format == null || q.format === '' ? 'html' : String(q.format);
58
+ if (!FORMAT_VALUES.includes(format)) return reply.status(400).send({error: `Unknown format "${format}"`});
59
+
60
+ const settings = getContentApiSettings();
61
+ const wanted = format === 'both' ? ['html', 'markdown'] : [format];
62
+ const res = await buildPageResponse(path, {access: ANONYMOUS, format});
63
+ return {
64
+ status: res.status,
65
+ body: res.body,
66
+ offered: wanted.every(f => settings.formats.includes(f)),
67
+ url: `${getBaseUrl(request)}/api/content/v1/pages${path}`
68
+ };
69
+ });
70
+ }