domma-cms 1.10.18 → 1.10.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.20] - 2026-10-25
4
+
5
+ - Forms support versioned JSON definition import and export from the list banner and item actions. Imports create a fresh private submissions collection, keep existing forms and collections, and assign a new slug on conflicts.
6
+ - Shared transfer services support Surveys and Campaigns Pro definition bundles without copying responses, recipients, invitations, delivery history or claims.
7
+
8
+ ## [1.10.19] - 2026-10-25
9
+
10
+ - **Built against domma-js 0.58.2** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.58.2).
11
+ - **Forms refuse every file the server would, before it is sent.** A file `/media/` would serve as a type no form collects - Markdown, JSON, calendar (`.ics`) and mail (`.eml`) files, stylesheets, code and the like - is now refused as it is dropped or picked ("… that kind of file cannot be uploaded here"), not only by the server after the upload. The page and the server use one list, made from the server's rule (`scripts/build-form-file-types.mjs`).
12
+ - **A visitor's file that fails to save part way (a full disk) is removed**, and a form upload is never written over an existing file.
13
+ - **The admin command palette's recent picks from "Open page…", "Open collection…" and "Open form…" survive a reload** (domma-js 0.58.2).
14
+ - **A cached page with a form keeps its file cap in step with server.json.** Changing `uploads.maxSubmissionBytes` (or `maxFileSize`) drops the cached pages that drew the old cap; no restart or form save needed.
15
+
3
16
  ## [1.10.18] - 2026-10-25
4
17
 
5
18
  - **A form's files are checked against the per-submission size cap in the browser.** A form with a file field now tells the page `uploads.maxSubmissionBytes`, so a file that would take the form's files past it is refused as it is dropped or picked ("… the files together would be more than 50 MB"), counting files kept on earlier wizard steps, and the total is checked again before posting - in the `[form]` shortcode and embedded forms alike. The server still enforces it.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.58.1
2
+ * Domma Tools CSS v0.58.2
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-25T09:08:50.530Z
6
- * Commit: bcc485e
5
+ * Built: 2026-10-25T15:33:34.161Z
6
+ * Commit: e547926
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.58.1
2
+ * Domma Tools v0.58.2
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-25T09:08:32.626Z
6
- * Commit: bcc485e
5
+ * Built: 2026-10-25T15:33:15.786Z
6
+ * Commit: e547926
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
@@ -0,0 +1,2 @@
1
+ import{apiRequest as c}from"/admin/js/api.js";const d=n=>String(n??"").replace(/[&<>"']/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t]);export async function exportDefinition(n){try{const t=await c(n),i=URL.createObjectURL(new Blob([JSON.stringify(t,null,4)+`
2
+ `],{type:"application/json"})),o=document.createElement("a");o.href=i,o.download=`${t.kind}-${t.definition.slug}.json`,document.body.appendChild(o),o.click(),o.remove(),setTimeout(()=>URL.revokeObjectURL(i),1e3)}catch(t){E.toast(`Export failed: ${t.message||t}`,{type:"error"})}}export function importDefinition(n,t,i,o){if(n.querySelector("[data-definition-import]"))return;const e=document.createElement("input");e.type="file",e.accept=".json,application/json",e.hidden=!0,e.dataset.definitionImport="true",n.appendChild(e),e.addEventListener("cancel",()=>e.remove(),{once:!0}),e.addEventListener("change",async()=>{try{const r=e.files?.[0];if(!r)return;if(r.size>1e6)throw new Error("The export must be smaller than 1 MB.");const a=JSON.parse(await r.text());if(a.format!=="domma-definition"||a.version!==1||a.kind!==i||!a.definition?.title)throw new Error(`Choose a Domma ${i} JSON export.`);const l=i==="form"?"The form settings and actions will be copied. It gets a new, empty submissions collection.":i==="survey"?"Its backing form is included. The survey starts as a draft with no audience or schedule.":"The campaign starts as a draft with no audience or sending schedule.";if(!await E.confirm(`Import <strong>${d(a.definition.title)}</strong>?<br>${l}<br>Existing items are kept. Naming conflicts get a new slug.`,{confirmText:"Import"})||!n.isConnected)return;const s=await c(t,{method:"POST",body:JSON.stringify(a)});E.toast(`Imported "${s.title}" as ${s.slug}.`,{type:"success"}),await o(s)}catch(r){E.toast(`Import failed: ${r.message||r}`,{type:"error"})}finally{e.remove()}},{once:!0}),e.click()}
@@ -1,4 +1,4 @@
1
- import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as ve,whileLoading as he}from"/admin/js/lib/loading.js";import{helpPopovers as Q}from"/admin/js/lib/help-popover.js";import{onceClosed as ge,openMenuAt as G,sidebarCount as P,viewLifetime as ye}from"/admin/js/lib/tool-kit.js";import{filterByProject as we,getProjectFromHash as xe,renderProjectFilterNotice as ke}from"/admin/js/lib/project-context.js";import{FILTERS as Ee,arrangeForms as Se,duplicatePayload as Te,formRow as Me,newFormDraftQuestion as Ce,shortcodeFor as $e,slugify as X}from"/admin/js/lib/forms-arrange.js";const Z=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,q="#/forms",x="forms.draft",j="forms.filter",De=`
1
+ import{exportDefinition as ve,importDefinition as he}from"/admin/js/lib/definition-transfer.js";import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as U}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as ge,whileLoading as ye}from"/admin/js/lib/loading.js";import{helpPopovers as Q}from"/admin/js/lib/help-popover.js";import{onceClosed as we,openMenuAt as G,sidebarCount as x,viewLifetime as xe}from"/admin/js/lib/tool-kit.js";import{filterByProject as ke,getProjectFromHash as Ee,renderProjectFilterNotice as Se}from"/admin/js/lib/project-context.js";import{FILTERS as Te,arrangeForms as Me,duplicatePayload as Ce,formRow as $e,newFormDraftQuestion as De,shortcodeFor as Fe,slugify as X}from"/admin/js/lib/forms-arrange.js";const Z=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,k="#/forms",T="forms.draft",j="forms.filter",Le=`
2
2
  <div class="fmx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="fmx-icon" aria-hidden="true"><span data-icon="layout" data-icon-size="16"></span></span>
4
4
  <span class="fmx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
@@ -10,7 +10,7 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
10
10
  </span>
11
11
  <span class="fmx-count" title="Submissions">{{totalText}}</span>
12
12
  <button type="button" class="fmx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
13
- </div>`,k=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");function Fe(m){const i=document.createElement("template");return i.innerHTML=m.trim(),i.content.firstElementChild}const c=ye(Z);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(m){c.reset();const i=m.get(0).querySelector(".fmx")||m.get(0),p=()=>Z.test(location.hash)&&i.isConnected,T=xe(),d=M.observable([]),B=M.observable(!0),C=M.observable("");let O="all";try{O=Ee.includes(S.get(j))?S.get(j):"all"}catch{}const b=M.observable(O),U=M.observable("");c.add(M.effect(()=>{const e=b.value;try{S.set(j,e)}catch{}}));const $=M.observable([]);c.add(M.effect(()=>{const e=Date.now();$.value=Se(d.value,{filter:b.value,query:U.value}).map(t=>Me(t,e))}));const ee=M.computed(()=>{const e=d.value;return{all:e.length,recent:e.filter(t=>Number(t.submissionsThisWeek)>0).length,empty:e.filter(t=>!Number(t.submissionCount)).length}}),te=M.computed(()=>{const e=d.value.reduce((t,s)=>t+(Number(s.submissionCount)||0),0);return d.value.length?`${e} submission${e===1?"":"s"} in all`:""}),se=M.computed(()=>d.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),D=e=>d.peek().find(t=>t.slug===e);async function _(){try{let e=await he(i.querySelector(".dm-loading-slot"),h("/forms"),ve.rows);if(!p())return;if(e=Array.isArray(e)?e:[],T){const t=e.length;e=we(e,T),ke(i,{total:t,shown:e.length,slug:T,allHref:"#/forms",noun:"forms"})}d.value=e,C.value=""}catch(e){C.value=`Could not load the forms: ${e.message||e}`}finally{B.value=!1}}const g=e=>{location.hash=`#/forms/edit/${encodeURIComponent(e)}`},ae=e=>{location.hash=`#/forms/${encodeURIComponent(e)}/submissions`};async function ne(e){const t=$e(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function oe(e){const t=e.plugin?`This form is managed by the <strong>${k(e.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${k(e.title||e.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(t))try{await h(`/forms/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),P(q),p()&&await _()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const F=new Set,y=new Set;let v=null,z=!1;function L({key:e,title:t,html:s,model:r,methods:n,dirty:o=()=>!1,cleanup:l=()=>{}}){if(y.has(e))return null;y.add(e);let a;try{a=E.slideover({title:t,size:"sm",position:"right"});const u=Fe(s);a.element.appendChild(u);const ue=Q(u),me=M.applyBindings(r,u,{methods:n});F.add(a),ge(a,()=>{y.delete(e),l(),me.dispose(),ue(),F.delete(a)});const fe=a.close.bind(a);let V=!1,R=!1;a.close=async N=>{if(!V&&N!==!0&&!z&&o()){if(R)return;R=!0;const be=await E.confirm("Throw away what you have typed?",{confirmText:"Throw away",cancelText:"Keep editing"});if(R=!1,!be)return}return V=!0,fe()};const pe=N=>{u.inert=!!N};return I.scan(u),a.open(),{so:a,body:u,lock:pe}}catch(u){y.delete(e);try{a?.destroy?.(),a?.element?.remove()}catch{}return E.toast(`Could not open the panel: ${u.message||u}`,{type:"error"}),null}}function K(e=null){const t={title:M.observable(e?.title??""),saving:M.observable(!1)};t.slug=M.computed(()=>X(t.title.value)),t.err=M.computed(()=>t.title.value.trim()?t.slug.value?D(t.slug.value)?`There is already a form "${t.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),t.hint=M.computed(()=>t.slug.value&&!t.err.value?`Its address will be "${t.slug.value}" - [form name="${t.slug.value}" /].`:""),t.cannot=M.computed(()=>t.saving.value||!t.title.value.trim()||!!t.err.value);const s=()=>!!t.title.peek().trim(),r={kind:"create",dirty:s,values:()=>({title:t.title.peek()})};async function n(){if(!t.cannot.peek()){t.saving.value=!0,o.lock(!0);try{const a=await h("/forms",{method:"POST",body:JSON.stringify({title:t.title.peek().trim()})});P(q),o.so.close(!0),A(),p()?g(a.slug):E.toast(`Form "${a.title||a.slug}" created.`,{type:"success"})}catch(a){o.lock(!1),E.toast(a.message||"Failed to create the form.",{type:"error"})}finally{t.saving.value=!1}}}const o=L({key:"create",title:"New form",model:t,dirty:s,cleanup:()=>{v===r&&(v=null)},methods:{save:n,cancel:()=>o.so.close()},html:`
13
+ </div>`,C=d=>String(d??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");function Ie(d){const l=document.createElement("template");return l.innerHTML=d.trim(),l.content.firstElementChild}const c=xe(Z);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(d){c.reset();const l=d.get(0).querySelector(".fmx")||d.get(0),p=()=>Z.test(location.hash)&&l.isConnected,$=Ee(),u=M.observable([]),B=M.observable(!0),D=M.observable("");let _="all";try{_=Te.includes(S.get(j))?S.get(j):"all"}catch{}const b=M.observable(_),z=M.observable("");c.add(M.effect(()=>{const e=b.value;try{S.set(j,e)}catch{}}));const F=M.observable([]);c.add(M.effect(()=>{const e=Date.now();F.value=Me(u.value,{filter:b.value,query:z.value}).map(t=>$e(t,e))}));const ee=M.computed(()=>{const e=u.value;return{all:e.length,recent:e.filter(t=>Number(t.submissionsThisWeek)>0).length,empty:e.filter(t=>!Number(t.submissionCount)).length}}),te=M.computed(()=>{const e=u.value.reduce((t,s)=>t+(Number(s.submissionCount)||0),0);return u.value.length?`${e} submission${e===1?"":"s"} in all`:""}),se=M.computed(()=>u.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),L=e=>u.peek().find(t=>t.slug===e);async function A(){try{let e=await ye(l.querySelector(".dm-loading-slot"),h("/forms"),ge.rows);if(!p())return;if(e=Array.isArray(e)?e:[],$){const t=e.length;e=ke(e,$),Se(l,{total:t,shown:e.length,slug:$,allHref:"#/forms",noun:"forms"})}u.value=e,D.value=""}catch(e){D.value=`Could not load the forms: ${e.message||e}`}finally{B.value=!1}}const g=e=>{location.hash=`#/forms/edit/${encodeURIComponent(e)}`},ae=e=>{location.hash=`#/forms/${encodeURIComponent(e)}/submissions`};async function ne(e){const t=Fe(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function oe(e){const t=e.plugin?`This form is managed by the <strong>${C(e.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${C(e.title||e.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(t))try{await h(`/forms/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),x(k),p()&&await A()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const R=new Set,y=new Set;let v=null,K=!1;function N({key:e,title:t,html:s,model:r,methods:n,dirty:o=()=>!1,cleanup:i=()=>{}}){if(y.has(e))return null;y.add(e);let a;try{a=E.slideover({title:t,size:"sm",position:"right"});const m=Ie(s);a.element.appendChild(m);const ue=Q(m),me=M.applyBindings(r,m,{methods:n});R.add(a),we(a,()=>{y.delete(e),i(),me.dispose(),ue(),R.delete(a)});const fe=a.close.bind(a);let Y=!1,q=!1;a.close=async O=>{if(!Y&&O!==!0&&!K&&o()){if(q)return;q=!0;const be=await E.confirm("Throw away what you have typed?",{confirmText:"Throw away",cancelText:"Keep editing"});if(q=!1,!be)return}return Y=!0,fe()};const pe=O=>{m.inert=!!O};return I.scan(m),a.open(),{so:a,body:m,lock:pe}}catch(m){y.delete(e);try{a?.destroy?.(),a?.element?.remove()}catch{}return E.toast(`Could not open the panel: ${m.message||m}`,{type:"error"}),null}}function H(e=null){const t={title:M.observable(e?.title??""),saving:M.observable(!1)};t.slug=M.computed(()=>X(t.title.value)),t.err=M.computed(()=>t.title.value.trim()?t.slug.value?L(t.slug.value)?`There is already a form "${t.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),t.hint=M.computed(()=>t.slug.value&&!t.err.value?`Its address will be "${t.slug.value}" - [form name="${t.slug.value}" /].`:""),t.cannot=M.computed(()=>t.saving.value||!t.title.value.trim()||!!t.err.value);const s=()=>!!t.title.peek().trim(),r={kind:"create",dirty:s,values:()=>({title:t.title.peek()})};async function n(){if(!t.cannot.peek()){t.saving.value=!0,o.lock(!0);try{const a=await h("/forms",{method:"POST",body:JSON.stringify({title:t.title.peek().trim()})});x(k),o.so.close(!0),P(),p()?g(a.slug):E.toast(`Form "${a.title||a.slug}" created.`,{type:"success"})}catch(a){o.lock(!1),E.toast(a.message||"Failed to create the form.",{type:"error"})}finally{t.saving.value=!1}}}const o=N({key:"create",title:"New form",model:t,dirty:s,cleanup:()=>{v===r&&(v=null)},methods:{save:n,cancel:()=>o.so.close()},html:`
14
14
  <div class="fmx-form">
15
15
  <div>
16
16
  <label class="form-label" for="fmx-new-title">Title
@@ -26,7 +26,7 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
26
26
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
27
27
  <span data-icon="plus" data-icon-size="13"></span> Create</button>
28
28
  </div>
29
- </div>`});if(!o)return!1;v=r;const l=o.body.querySelector("#fmx-new-title");return l.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),n())}),setTimeout(()=>l?.focus(),50),!0}function le(e){const t=`${e.title||e.slug} (Copy)`,s={title:M.observable(t),saving:M.observable(!1)};s.slug=M.computed(()=>X(s.title.value)),s.err=M.computed(()=>s.slug.value?D(s.slug.value)?`There is already a form "${s.slug.value}".`:"":"The title needs at least one letter or number."),s.cannot=M.computed(()=>s.saving.value||!!s.err.value);async function r(){if(!s.cannot.peek()){s.saving.value=!0,n.lock(!0);try{const l=await h(`/forms/${encodeURIComponent(e.slug)}`),a=await h("/forms",{method:"POST",body:JSON.stringify(Te(l,s.title.peek()))});E.toast(`Duplicated as "${a.title}".`,{type:"success"}),P(q),n.so.close(!0),p()&&g(a.slug)}catch(l){n.lock(!1),E.toast(l.message||"Failed to duplicate the form.",{type:"error"})}finally{s.saving.value=!1}}}const n=L({key:`duplicate:${e.slug}`,title:`Duplicate ${k(e.title||e.slug)}`,model:s,dirty:()=>s.title.peek().trim()!==t,methods:{save:r,cancel:()=>n.so.close()},html:`
29
+ </div>`});if(!o)return!1;v=r;const i=o.body.querySelector("#fmx-new-title");return i.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),n())}),setTimeout(()=>i?.focus(),50),!0}function le(e){const t=`${e.title||e.slug} (Copy)`,s={title:M.observable(t),saving:M.observable(!1)};s.slug=M.computed(()=>X(s.title.value)),s.err=M.computed(()=>s.slug.value?L(s.slug.value)?`There is already a form "${s.slug.value}".`:"":"The title needs at least one letter or number."),s.cannot=M.computed(()=>s.saving.value||!!s.err.value);async function r(){if(!s.cannot.peek()){s.saving.value=!0,n.lock(!0);try{const i=await h(`/forms/${encodeURIComponent(e.slug)}`),a=await h("/forms",{method:"POST",body:JSON.stringify(Ce(i,s.title.peek()))});E.toast(`Duplicated as "${a.title}".`,{type:"success"}),x(k),n.so.close(!0),p()&&g(a.slug)}catch(i){n.lock(!1),E.toast(i.message||"Failed to duplicate the form.",{type:"error"})}finally{s.saving.value=!1}}}const n=N({key:`duplicate:${e.slug}`,title:`Duplicate ${C(e.title||e.slug)}`,model:s,dirty:()=>s.title.peek().trim()!==t,methods:{save:r,cancel:()=>n.so.close()},html:`
30
30
  <div class="fmx-form">
31
31
  <div>
32
32
  <label class="form-label" for="fmx-dup-title">Title of the copy</label>
@@ -40,7 +40,7 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
40
40
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
41
41
  <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
42
42
  </div>
43
- </div>`});if(!n)return;const o=n.body.querySelector("#fmx-dup-title");o.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),r())}),setTimeout(()=>{o?.focus(),o?.select()},50)}function ie(){const e={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};e.cannot=M.computed(()=>e.sending.value||!/^[^\s@]+@[^\s@]+$/.test(e.to.value.trim()));async function t(){if(!e.cannot.peek()){e.sending.value=!0,s.lock(!0);try{await h("/forms/test-email",{method:"POST",body:JSON.stringify({to:e.to.peek().trim()})}),s.so.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){s.lock(!1),E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{e.sending.value=!1}}}const s=L({key:"test-email",title:"Send test email",model:e,methods:{send:t,cancel:()=>s.so.close()},html:`
43
+ </div>`});if(!n)return;const o=n.body.querySelector("#fmx-dup-title");o.addEventListener("keydown",i=>{i.key==="Enter"&&(i.preventDefault(),r())}),setTimeout(()=>{o?.focus(),o?.select()},50)}function ie(){const e={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};e.cannot=M.computed(()=>e.sending.value||!/^[^\s@]+@[^\s@]+$/.test(e.to.value.trim()));async function t(){if(!e.cannot.peek()){e.sending.value=!0,s.lock(!0);try{await h("/forms/test-email",{method:"POST",body:JSON.stringify({to:e.to.peek().trim()})}),s.so.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){s.lock(!1),E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{e.sending.value=!1}}}const s=N({key:"test-email",title:"Send test email",model:e,methods:{send:t,cancel:()=>s.so.close()},html:`
44
44
  <div class="fmx-form">
45
45
  <div>
46
46
  <label class="form-label" for="fmx-test-to">Send to
@@ -54,4 +54,4 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
54
54
  <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="cannot.value">
55
55
  <span data-icon="mail" data-icon-size="13"></span> Send</button>
56
56
  </div>
57
- </div>`});if(!s)return;const r=s.body.querySelector("#fmx-test-to");r.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),t())}),setTimeout(()=>r?.focus(),50)}function re(e){const t=D(e);if(!t)return[];const s=Number(t.submissionCount)||0;return[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>g(t.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ae(t.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>ne(t)},{label:"Duplicate\u2026",icon:"copy",action:()=>le(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>oe(t)}]}const f=i.querySelector(".fmx-list");f.innerHTML=De;const ce=M.applyBindings({forms:d,loading:B,loadError:C,filter:b,query:U,rows:$,counts:ee,summary:te,emptyText:se},i,{methods:{showAll:()=>{b.value="all"},showRecent:()=>{b.value="recent"},showEmpty:()=>{b.value="empty"}}});c.add(()=>ce.dispose()),c.add(Q(i,{position:"bottom"})),c.add(M.effect(()=>{$.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const de=E.contextMenu(f,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:e=>re(e.dataset.slug)});c.add(()=>de?.destroy?.());const H=e=>{const t=e.target.closest(".fmx-row");if(!t)return;const s=e.target.closest(".fmx-kebab");if(s){G(s);return}g(t.dataset.slug)},W=e=>{const t=e.target.closest(".fmx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),G(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.slug))};f.addEventListener("click",H),f.addEventListener("keydown",W),c.add(()=>{f.removeEventListener("click",H),f.removeEventListener("keydown",W)}),Y(m,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>K()}),Y(m,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ie()});const A=()=>{try{S.remove?S.remove(x):S.set(x,null)}catch{}},J=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",J),c.add(()=>window.removeEventListener("beforeunload",J)),c.add(()=>{if(v?.dirty())try{S.set(x,{values:v.values(),at:new Date().toISOString()})}catch{}z=!0,[...F].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(i),await _(),!p())return;let w=null;try{w=S.get(x)}catch{}if(w?.values?.title){const e=Ce(w,d.peek());if(await E.confirm(k(e),{title:"Unsaved new form",confirmText:"Throw away",cancelText:"Keep it"})){A();return}if(!p())return;K(w.values)&&A()}}};
57
+ </div>`});if(!s)return;const r=s.body.querySelector("#fmx-test-to");r.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),t())}),setTimeout(()=>r?.focus(),50)}function re(e){const t=L(e);if(!t)return[];const s=Number(t.submissionCount)||0;return[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>g(t.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ae(t.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>ne(t)},{label:"Export JSON",icon:"download",action:()=>ve(`/forms/${encodeURIComponent(t.slug)}/export`)},{label:"Duplicate\u2026",icon:"copy",action:()=>le(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>oe(t)}]}const f=l.querySelector(".fmx-list");f.innerHTML=Le;const ce=M.applyBindings({forms:u,loading:B,loadError:D,filter:b,query:z,rows:F,counts:ee,summary:te,emptyText:se},l,{methods:{showAll:()=>{b.value="all"},showRecent:()=>{b.value="recent"},showEmpty:()=>{b.value="empty"}}});c.add(()=>ce.dispose()),c.add(Q(l,{position:"bottom"})),c.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const de=E.contextMenu(f,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:e=>re(e.dataset.slug)});c.add(()=>de?.destroy?.());const J=e=>{const t=e.target.closest(".fmx-row");if(!t)return;const s=e.target.closest(".fmx-kebab");if(s){G(s);return}g(t.dataset.slug)},W=e=>{const t=e.target.closest(".fmx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),G(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.slug))};f.addEventListener("click",J),f.addEventListener("keydown",W),c.add(()=>{f.removeEventListener("click",J),f.removeEventListener("keydown",W)}),U(d,{id:"forms-import",icon:"upload",title:"Import",onClick:()=>he(l,"/forms/import","form",async()=>{x(k),p()&&await A()})}),U(d,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>H()}),U(d,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ie()});const P=()=>{try{S.remove?S.remove(T):S.set(T,null)}catch{}},V=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",V),c.add(()=>window.removeEventListener("beforeunload",V)),c.add(()=>{if(v?.dirty())try{S.set(T,{values:v.values(),at:new Date().toISOString()})}catch{}K=!0,[...R].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await A(),!p())return;let w=null;try{w=S.get(T)}catch{}if(w?.values?.title){const e=De(w,u.peek());if(await E.confirm(C(e),{title:"Unsaved new form",confirmText:"Throw away",cancelText:"Keep it"})){P();return}if(!p())return;H(w.values)&&P()}}};
@@ -29,7 +29,7 @@ Controls the HTTP server.
29
29
  | `csp.connectHosts` | string[] | `[]` | Other sites a public page may read from in the browser (`fetch`), added to the Content-Security-Policy's `connect-src` as `https://<host>`. The site itself and cdn.jsdelivr.net are always allowed. Bare hostnames only (`dcm.dommajs.org`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
30
30
  | `csp.imgHosts` | string[] | `[]` | Other sites a public page may show images from (an `<img>` hosted elsewhere, such as a badge or partner logo), added to the Content-Security-Policy's `img-src` as `https://<host>`. The site itself, `data:` and `blob:` are always allowed; without this an image from another site is blocked - upload it to Media instead if you can. Bare hostnames only (`statics.startupbase.io`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
31
31
  | `uploads.maxFileSize` | number | `10485760` (10 MB) | Maximum allowed upload size in bytes |
32
- | `uploads.maxSubmissionBytes` | number | 50 MB, or `uploads.maxFileSize` when that is larger | The most a public form submission's files may add up to, all file fields together. The server holds a submission's files in memory until every check has passed, so it stops reading and refuses the submission (`400`, nothing written) the moment the running total passes this. Read fresh on every submission. A form with a file field is sent to the page with `fileLimits.maxTotalBytes`, so the page refuses a file that would pass it as it is added, and checks again before posting. A page with a `[form]` that is already in the response cache keeps the cap it was drawn with until the site restarts or the form is saved |
32
+ | `uploads.maxSubmissionBytes` | number | 50 MB, or `uploads.maxFileSize` when that is larger | The most a public form submission's files may add up to, all file fields together. The server holds a submission's files in memory until every check has passed, so it stops reading and refuses the submission (`400`, nothing written) the moment the running total passes this. Read fresh on every submission. A form with a file field is sent to the page with `fileLimits.maxTotalBytes`, so the page refuses a file that would pass it as it is added, and checks again before posting. A cached page with a `[form]` is dropped when a change to server.json changes the cap, so it is drawn again with the new one |
33
33
  | `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. |
34
34
  | `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
35
35
  | `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` |
@@ -0,0 +1,11 @@
1
+ # Import and export definitions
2
+
3
+ Forms, Surveys Pro and Campaigns Pro support portable JSON definitions.
4
+
5
+ Open an item's actions menu and choose **Export JSON**. In the destination site's list, choose **Import**, select that file and review the confirmation. Imports create new items. Existing slugs receive a numeric suffix instead of being overwritten.
6
+
7
+ - Forms include fields, page breaks, conditional rules, settings and actions. An imported form gets a fresh, private submissions collection. Review email, webhook and CMS action settings for the destination site.
8
+ - Surveys include their backing form, including wizard steps. The imported survey points to its imported form and starts as a draft. Select its audience and dates before opening it.
9
+ - Campaigns include their message, links and offer settings. The imported campaign starts as a draft. Select recipients and a sending schedule before sending it.
10
+
11
+ Definitions do not include submissions, responses, audience contacts, invitations, delivery history, claims or sending credentials. Existing response exports remain available separately. Imports accept version 1 Domma JSON exports smaller than 1 MB.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.18",
3
+ "version": "1.10.20",
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",
@@ -86,7 +86,7 @@
86
86
  "bcryptjs": "^3.0.3",
87
87
  "css-select": "^7.0.0",
88
88
  "dom-serializer": "^3.1.1",
89
- "domma-js": "0.58.1",
89
+ "domma-js": "0.58.2",
90
90
  "dotenv": "^17.2.3",
91
91
  "fastify": "5.12.7",
92
92
  "gray-matter": "^4.0.3",
@@ -1 +1 @@
1
- export const DEFAULT_MAX_SIZE=5242880,DEFAULT_MAX_FILES=5,MAX_FILES_LIMIT=20;export function fileRules(e){const t=e&&e.file||{},n=!!(e&&e.multiple),o=Math.floor(Number(t.maxFiles));return{accept:String(t.accept||"").split(",").map(r=>r.trim()).filter(Boolean),maxSize:Number(t.maxSize)||5242880,multiple:n,maxFiles:n?Math.min(o>=1?o:5,20):1}}function d(e){return e===1?"one file":`${e} files`}export function countRefusal(e,t){const{maxFiles:n,multiple:o}=fileRules(e);if(Number(t)<=n)return null;const r=e&&(e.label||e.name)||"File";return o?`"${r}" takes at most ${d(n)}.`:`"${r}" takes one file.`}export function fileRefs(e){return(Array.isArray(e)?e:[e]).filter(n=>n&&typeof n=="object"&&!Array.isArray(n)&&typeof n.url=="string"&&(typeof n.name=="string"||n.name===void 0))}export function isFileValue(e){const t=fileRefs(e);return t.length>0&&t.length===(Array.isArray(e)?e.length:1)}export function fileValueText(e,{base:t="",urls:n=!0}={}){return fileRefs(e).map(o=>{const r=o.name||o.url.split("/").pop()||"file";return!n||!o.url?r:`${r} (${fileUrl(o,t)})`}).join("; ")}export function fileUrl(e,t=""){const n=String(e&&e.url||""),o=String(t||"").replace(/\/+$/,"");return o&&n.startsWith("/")?`${o}${n}`:n}export function mimeAccepted(e,t){if(!e||!e.length)return!0;const n=String(t||"");return e.some(o=>o.endsWith("/*")?n.startsWith(o.slice(0,-1)):o===n)}export function cleanUploadName(e){const t=n=>n.replace(/^[-.]+|[-.]+$/g,"");return t(t(String(e||"upload").toLowerCase().replace(/[^\w.-]+/g,"-").replace(/-+/g,"-")).slice(0,200))||"upload"}const y=/^(text\/html|text\/xml|text\/xsl|text\/javascript|text\/ecmascript|application\/(x-)?javascript|application\/ecmascript|application\/xml|application\/[\w.-]+\+xml|image\/svg\+xml)\b/i,b=/\.(html?|xht(ml)?|shtml?|svgz?|xml|xsd|xslt?|xul|rdf|owl|atom|rss|wsdl|smil?|kml|opf|xlf|gpx|ttml|mathml|mml|xop|m?js|mht(ml)?)$/i;function z(e){const t=/\.[^.]+$/.exec(cleanUploadName(e&&(e.filename??e.name)||""));return t?t[0]:""}export function isActiveFile(e){const t=String(e&&(e.mimetype??e.type)||""),n=e&&(e.filename??e.name)||"";return y.test(t)||b.test(cleanUploadName(n))}export function fileRefusal(e,t){const n=fileRules(e),o=e&&(e.label||e.name)||"File";if(Number(t&&t.size)>n.maxSize)return`"${o}" file is too large (max ${Math.round(n.maxSize/1024)} KB).`;const r=t&&(t.mimetype??t.type)||"";return isActiveFile(t)?`"${o}" file type "${r||z(t)}" not allowed (it could run as a page on this site).`:n.accept.length&&!mimeAccepted(n.accept,r)?`"${o}" file type "${r}" not allowed (expected: ${String(e.file.accept).trim()}).`:null}const $=[[/wordprocessingml|^msword$/,"Word"],[/spreadsheetml|ms-excel/,"Excel"],[/presentationml|ms-powerpoint/,"PowerPoint"],[/^plain$/,"text"]];export function describeAccept(e){const t=[];return(e||[]).forEach(n=>{const[o,r]=String(n).split("/");let s;r==="*"?s={image:"images",audio:"audio",video:"video",text:"text files"}[o]||`${o} files`:r?s=$.find(([i])=>i.test(r))?.[1]||r.replace(/^(x-|vnd\.)/,"").split(/[.+]/)[0].toUpperCase():s=String(n),s&&!t.includes(s)&&t.push(s)}),t.length<2?t[0]||"":`${t.slice(0,-1).join(", ")} or ${t[t.length-1]}`}export function formatSize(e){const t=Number(e)||0;if(t<1024)return`${t} B`;const n=["KB","MB","GB"];let o=t,r=-1;do o/=1024,r++;while(o>=1024&&r<n.length-1);return`${o>=10?Math.round(o):Math.round(o*10)/10} ${n[r]}`}export function fileHint(e){const t=fileRules(e),n=describeAccept(t.accept),o=t.multiple?`up to ${d(t.maxFiles)}, ${formatSize(t.maxSize)} each`:`up to ${formatSize(t.maxSize)}`;return n?`${n[0].toUpperCase()}${n.slice(1)} - ${o}`:`Any file - ${o}`}export function dropzoneOptions(e){const t=fileRules(e);return{accept:t.accept.length?t.accept.join(","):null,maxSize:t.maxSize,multiple:t.multiple,maxFiles:t.multiple?t.maxFiles:null,labels:{prompt:t.multiple?"Drop files here, paste, or click to browse":"Drop a file here, paste, or click to browse",hint:fileHint(e),required:"Please add a file",type:refusalMessage(e,{name:"{name}"},"type"),size:refusalMessage(e,{name:"{name}"},"size"),count:refusalMessage(e,{name:"{name}"},"count")}}}export function fileBlueprint(e,t){return{...t,type:"dropzone",formConfig:{...t.formConfig||{},...dropzoneOptions(e)}}}export function refusalMessage(e,t,n,o){const r=fileRules(e),s=t&&t.name||"That file";if(n==="total")return`${s} was not added: the files together would be more than ${formatSize(o)}.`;if(n==="size")return`${s} was not added: it is larger than ${formatSize(r.maxSize)}.`;if(n==="type"){const i=describeAccept(r.accept);return`${s} was not added: ${i?`only ${i} can be uploaded here`:"that type of file is not accepted"}.`}return n==="active"?`${s} was not added: web pages, SVG images and scripts cannot be uploaded.`:`${s} was not added: only ${d(r.maxFiles)} can be added here.`}function S(e){return Array.isArray(e)&&e.length>0&&typeof File<"u"&&e.every(t=>t instanceof File)}export function splitFiles(e,t,n){const o={...e||{}},r=new Map,s=new Set((t||[]).filter(i=>i&&i.type==="file"&&i.name).map(i=>i.name));for(const[i,l]of Object.entries(o))S(l)?(r.set(i,l),delete o[i]):s.has(i)&&(Array.isArray(l)||l==null)&&(o[i]="");for(const[i,l]of n||[])r.set(i,l||[]);for(const[i,l]of r)l.length?delete o[i]:(r.delete(i),(s.has(i)||i in o)&&(o[i]=""));return{values:o,files:r}}export function submitBody(e,t){if(![...(t||new Map).values()].some(r=>r&&r.length))return{body:JSON.stringify(e),headers:{"Content-Type":"application/json"},multipart:!1};const o=new FormData;for(const[r,s]of Object.entries(e))s==null||t.has(r)||o.append(r,typeof s=="object"?JSON.stringify(s):String(s));for(const[r,s]of t)for(const i of s||[])o.append(r,i,i.name);return{body:o,headers:{},multipart:!0}}export function captureFileFields(e,t){!e||!t||e.querySelectorAll('input[type="file"]').forEach(n=>{n.name&&t.set(n.name,Array.from(n.files||[]))})}export function restoreFileFields(e,t){!e||!t||!t.size||e.querySelectorAll('input[type="file"]').forEach(n=>{const o=t.get(n.name);if(!o||!o.length||n.files&&n.files.length)return;const r=g(n);if(r){r.addFiles(o);return}try{const s=new DataTransfer;o.forEach(i=>s.items.add(i)),n.files=s.files}catch{}})}function g(e){const t=globalThis.E;if(!t||typeof t.get!="function")return null;const n=e.closest("[data-dropzone-field]"),o=n&&t.get(n,"dropzone")||t.get(e,"dropzone");return o&&typeof o.addFiles=="function"?o:null}function m(e,t){const n=String(t&&t.name||"").replace(/\[\]$/,"");return(e||[]).find(o=>o&&o.name===n)||{name:n}}function E(e){let t=e.nextElementSibling;return(!t||!t.classList.contains("fb-file-refusal"))&&(t=document.createElement("p"),t.className="fb-file-refusal",t.hidden=!0,e.after(t)),t}export function submissionTooLarge(e){const t=e/1048576;return`The files sent are too large together (at most ${t>=1?`${Math.round(t*10)/10} MB`:`${Math.round(e/1024)} KB`} in one submission). Send fewer or smaller files.`}export function setFileLimits(e,t,n){e&&(e._fbFileLimits=t||null,n&&(e._fbRetained=n))}function p(e){return Number(e&&e._fbFileLimits&&e._fbFileLimits.maxTotalBytes)||0}function f(e){let t=0;for(const n of e||[])t+=Number(n&&n.size)||0;return t}export function totalTooLarge(e,t){const n=p(e);if(!n||!t)return null;let o=0;for(const r of t.values())o+=f(r);return o>n?submissionTooLarge(n):null}function F(e,t){const n=globalThis.E,o=a=>n&&typeof n.get=="function"?n.get(a,"dropzone"):null,r=new Set;e.querySelectorAll('.dm-dropzone, [data-dropzone-field], input[type="file"]').forEach(a=>{const c=o(a);c&&r.add(c)});const s=new Set,i=new Set;let l=0;for(const a of r)a.input&&(s.add(a.input),a.input.name&&i.add(a.input.name)),a.zone&&a.zone.querySelectorAll('input[type="file"]').forEach(c=>s.add(c)),a!==t&&(l+=f(a.getFiles()));e.querySelectorAll('input[type="file"]').forEach(a=>{s.has(a)||(a.name&&i.add(a.name),l+=f(a.files))});for(const[a,c]of e._fbRetained||[])i.has(a)||(l+=f(c));return l}export function wireFileFields(e,t){if(!e||e._fbFilesWired)return;e._fbFilesWired=!0;const n=new WeakMap,o=(s,i,l)=>{const a=E(s.zone),c=n.get(s.zone)||[];c.push(refusalMessage(m(t,s.input),i,l,p(e))),n.set(s.zone,c),a.textContent=c.join(" "),a.hidden=!1,s.zone.classList.add("is-invalid"),queueMicrotask(()=>n.delete(s.zone))};e.addEventListener("dm:dropzone:reject",s=>{const i=s.detail&&s.detail.instance,[l,a]=s.detail&&s.detail.args||[];i&&i.zone&&o(i,l,a)});const r=new Map;e.addEventListener("dm:dropzone:beforeadd",s=>{const i=s.detail&&s.detail.instance,[l]=s.detail&&s.detail.args||[];if(!i||!i.zone||!l)return;const a=l.file||l;if(isActiveFile(a)){s.preventDefault(),o(i,l,"active");return}const c=p(e);if(!c)return;const u=i.options&&i.options.multiple===!1,h=Number(l.size??a.size)||0,x=u?0:r.get(i)||0;if(F(e,u?i:null)+x+h>c){s.preventDefault(),o(i,l,"total");return}u||(r.has(i)||queueMicrotask(()=>r.delete(i)),r.set(i,x+h))}),e.addEventListener("dm:dropzone:change",s=>{const i=s.detail&&s.detail.instance;i&&r.delete(i)}),e.addEventListener("dm:dropzone:add",s=>{const i=s.detail&&s.detail.instance;if(!i||!i.zone||n.get(i.zone))return;const l=i.zone.nextElementSibling;l&&l.classList.contains("fb-file-refusal")&&(l.hidden=!0,l.textContent="")}),e.addEventListener("dm:dropzone:change",s=>{const i=s.detail&&s.detail.instance;if(!i||!i.input)return;i.input.dispatchEvent(new Event("input",{bubbles:!0}));const l=i.input.closest(".fb-field-invalid");l&&i.getFiles().length&&l.classList.remove("fb-field-invalid")})}export function enhanceFileInputs(e,t){const n=globalThis.E;!e||!n||typeof n.dropzone!="function"||(e.querySelectorAll('input[type="file"]').forEach(o=>{g(o)||n.dropzone(o,dropzoneOptions(m(t,o)))}),wireFileFields(e,t))}export function checkFileInputs(e,t){const n=[];return e&&e.querySelectorAll('input[type="file"]').forEach(o=>{const r=m(t,o),s=Array.from(o.files||[]),i=countRefusal(r,s.length);i&&n.push(i),s.forEach(l=>{const a=fileRefusal(r,l);a&&n.push(a)})}),n}export function destroyDropzones(e){const t=globalThis.E;!e||!t||typeof t.get!="function"||e.querySelectorAll('.dm-dropzone, input[type="file"]').forEach(n=>{const o=t.get(n,"dropzone");o&&typeof o.destroy=="function"&&o.destroy()})}
1
+ export const DEFAULT_MAX_SIZE=5242880,DEFAULT_MAX_FILES=5,MAX_FILES_LIMIT=20;export function fileRules(e){const t=e&&e.file||{},s=!!(e&&e.multiple),n=Math.floor(Number(t.maxFiles));return{accept:String(t.accept||"").split(",").map(l=>l.trim()).filter(Boolean),maxSize:Number(t.maxSize)||5242880,multiple:s,maxFiles:s?Math.min(n>=1?n:5,20):1}}function f(e){return e===1?"one file":`${e} files`}export function countRefusal(e,t){const{maxFiles:s,multiple:n}=fileRules(e);if(Number(t)<=s)return null;const l=e&&(e.label||e.name)||"File";return n?`"${l}" takes at most ${f(s)}.`:`"${l}" takes one file.`}export function fileRefs(e){return(Array.isArray(e)?e:[e]).filter(s=>s&&typeof s=="object"&&!Array.isArray(s)&&typeof s.url=="string"&&(typeof s.name=="string"||s.name===void 0))}export function isFileValue(e){const t=fileRefs(e);return t.length>0&&t.length===(Array.isArray(e)?e.length:1)}export function fileValueText(e,{base:t="",urls:s=!0}={}){return fileRefs(e).map(n=>{const l=n.name||n.url.split("/").pop()||"file";return!s||!n.url?l:`${l} (${fileUrl(n,t)})`}).join("; ")}export function fileUrl(e,t=""){const s=String(e&&e.url||""),n=String(t||"").replace(/\/+$/,"");return n&&s.startsWith("/")?`${n}${s}`:s}export function mimeAccepted(e,t){if(!e||!e.length)return!0;const s=String(t||"");return e.some(n=>n.endsWith("/*")?s.startsWith(n.slice(0,-1)):n===s)}export function cleanUploadName(e){const t=s=>s.replace(/^[-.]+|[-.]+$/g,"");return t(t(String(e||"upload").toLowerCase().replace(/[^\w.-]+/g,"-").replace(/-+/g,"-")).slice(0,200))||"upload"}const z=/^(text\/html|text\/xml|text\/xsl|text\/javascript|text\/ecmascript|application\/(x-)?javascript|application\/ecmascript|application\/xml|application\/[\w.-]+\+xml|image\/svg\+xml)\b/i,y=/\.(html?|xht(ml)?|shtml?|svgz?|xml|xsd|xslt?|xul|rdf|owl|atom|rss|wsdl|smil?|kml|opf|xlf|gpx|ttml|mathml|mml|xop|m?js|mht(ml)?)$/i,w=new Set("123 1km 3dml 3mf aab aam aas abw ac acc ace acu acutc aep afm afp ahead ai air ait ami apk appcache application apr arc arj asc asm aso atc atom atomcat atomdeleted atomsvc atx aw azf azs azw bat bcpio bdf bdm bdoc bed bh2 blb blorb bmi bmml book box boz bsp bz bz2 c c11amc c11amz c4d c4f c4g c4p c4u cap car cb7 cba cbr cbt cbz cc cco cct ccxml cdbcmsg cdf cdfx cdkey cdmia cdmic cdmid cdmio cdmiq cdx cdxml cdy cer cfs chat chrt cif cii cjs cla class clkk clkp clkt clkw clkx clp cmc cmdf cml cmp cod coffee com cpio cpp cpt crd crl crt crx cryptonote csh csl csml csp css cst cu curl cww cxt cxx dae daf dart dataless davmount dbf dbk dcr dcurl dd2 ddd ddf der dfac dgc dic dir dis disposition-notification dmp dna dp dpg dsc dssc dtb dtd dvi dwd dwf dxp dxr ear ecma edm edx efif ei6 eml emma emotionml emz eps es es3 esa esf et3 etx eva evy exi exp ext ez ez2 ez3 f f77 f90 fcdt fcs fdf fdt fe_launch fg5 fgd fig flo flw flx fly fm fnc fo for frame fsc ftc fti fxp fxpl fzs g2w g3w gac gam gbr gca gdl gdoc geo geojson gex ggb ggt ghf gim glb gltf gml gmx gnumeric gph gpx gqf gqs gram gramps gre grv grxml gsf gsheet gslides gtar gtm gtw gv gxf gxt h hal hbci hbs hdd hdf held hh hjson hlp hpgl hpid hps hqx htc htke htm html hvd hvp hvs i2g icc ice icm ics ifb ifm iges igl igm igs igx iif imp ink inkml install iota ipfix ipk irm irp itp its ivp ivu jad jade jam jar jardiff java jisp jlt jnlp joda js json json5 jsonld jsonml jsx karbon kdbx key kfo kia kml kmz kne knp kon kpr kpt kpxx ksp ktr ktz kwd kwt lasxml latex lbd lbe les less lgr lha link66 list3820 listafp litcoffee lnk lostxml ltf lua luac lwp lzh m13 m14 m21 m3u8 m4p ma mads maei mag maker man manifest map markdown mathml mb mbk mbox mc1 mcd mcurl md mdb mdx me mesh meta4 metalink mets mfm mft mgp mgz mie mif mime mjs mkd mlp mmd mmf mml mny mobi mods mp21 mp4s mpc mpd mpkg mpm mpn mpy mqy mrc mrcx ms mscml mseed mseq msf msh msl msty mtl mts mus musd musicxml mvb mvt mwf mxf mxl mxml mxs n-gage n3 nb nbp nc ncx nfo ngdat nitf nlu nml nnd nns nnw nq nsc nsf nt ntf numbers nzb oa2 oa3 oas obd obgx obj oda ogex ogx omdoc onepkg onetmp onetoc onetoc2 opf opml oprc org osf osfpvg osm otf ova ovf owl oxps oxt p p10 p12 p7b p7c p7m p7r p7s p8 pac pages pas paw pbd pcap pcf pcl pclxl pcurl pdb pde pem pfa pfb pfm pfr pfx pgn pgp php pki pkipath pkpass pl plb plc plf pls pm pml portpkg ppd pqa prc pre prf provx ps psb psf pskcxml ptid pub pvb pwn qam qbo qfx qps qwd qwt qxb qxd qxl qxt raml rapd rcprofile rdf rdz relo rep res rif ris rl rld rm rms rmvb rnc rng roa roff rp9 rpm rpss rpst rq rs rsat rsd rsheet rss rtx run rusd s saf sass sbml sc scd scm scq scs scss scurl sda sdc sdd sdkd sdkm sdp sdw sea see seed sema semd semf senmlx sensmlx ser setpay setreg sfd-hdstx sfs sfv sgl sgm sgml sh shar shex shf shtml sieve sig silo sis sisx sit sitx siv skd skm skp skt slim slm sls slt sm smf smi smil smzip snf spc spdx spf spl spot spp spq sql src srt sru srx ssdl sse ssf ssml st stc std stf sti stk stl stpx stpxz stpz str stw styl stylus sub sus susp sv4cpio sv4crc svc svd svg svgz swa swf swi swidtag sxc sxd sxg sxi sxm sxw t t3 taglet tao tcap tcl td teacher tei teicorpus tex texi texinfo tfi tfm tk tmo toml torrent tpl tpt tr tra trig trm tsd tsv ttc ttf ttl ttml twd twds txd txf u32 u8dsn u8hdr u8mdn u8msg ubj udeb ufd ufdl ulx umj unityweb uoml uri uris urls usdz ustar utz uu uvd uvf uvt uvvd uvvf uvvt uvvx uvvz uvx uvz vbox vbox-extpack vcard vcd vcf vcg vcs vcx vdi vds vhd vis vmdk vor vox vrml vsd vsf vss vst vsw vtt vtu vxml w3d wad wadl war wasm wbs wbxml webapp webmanifest wg wgt wmd wml wmlc wmls wmlsc wmz woff woff2 wpd wqd wri wrl wsc wsdl wspolicy wtb x32 x3d x3db x3dbz x3dv x3dvz x3dz x_b x_t xaml xap xar xav xbap xbd xca xcs xdf xdm xdp xdssc xdw xel xenc xer xfdf xfdl xht xhtml xhvml xlf xml xns xo xop xpi xpl xpr xpw xpx xsd xsl xslt xsm xspf xul xvm xvml xyz xz yaml yang yin yml ymp z1 z2 z3 z4 z5 z6 z7 z8 zaz zir zirz zmm ".trim().split(" "));export function isRefusedKind(e){const t=cleanUploadName(e&&(e.filename??e.name)||"");return w.has(t.replace(/^.*\./,""))}function h(e){const t=/\.[^.]+$/.exec(cleanUploadName(e&&(e.filename??e.name)||""));return t?t[0]:""}export function isActiveFile(e){const t=String(e&&(e.mimetype??e.type)||""),s=e&&(e.filename??e.name)||"";return z.test(t)||y.test(cleanUploadName(s))}export function fileRefusal(e,t){const s=fileRules(e),n=e&&(e.label||e.name)||"File";if(Number(t&&t.size)>s.maxSize)return`"${n}" file is too large (max ${Math.round(s.maxSize/1024)} KB).`;const l=t&&(t.mimetype??t.type)||"";return isActiveFile(t)?`"${n}" file type "${l||h(t)}" not allowed (it could run as a page on this site).`:isRefusedKind(t)?`"${n}" file type "${h(t)||l}" cannot be uploaded here.`:s.accept.length&&!mimeAccepted(s.accept,l)?`"${n}" file type "${l}" not allowed (expected: ${String(e.file.accept).trim()}).`:null}const v=[[/wordprocessingml|^msword$/,"Word"],[/spreadsheetml|ms-excel/,"Excel"],[/presentationml|ms-powerpoint/,"PowerPoint"],[/^plain$/,"text"]];export function describeAccept(e){const t=[];return(e||[]).forEach(s=>{const[n,l]=String(s).split("/");let o;l==="*"?o={image:"images",audio:"audio",video:"video",text:"text files"}[n]||`${n} files`:l?o=v.find(([i])=>i.test(l))?.[1]||l.replace(/^(x-|vnd\.)/,"").split(/[.+]/)[0].toUpperCase():o=String(s),o&&!t.includes(o)&&t.push(o)}),t.length<2?t[0]||"":`${t.slice(0,-1).join(", ")} or ${t[t.length-1]}`}export function formatSize(e){const t=Number(e)||0;if(t<1024)return`${t} B`;const s=["KB","MB","GB"];let n=t,l=-1;do n/=1024,l++;while(n>=1024&&l<s.length-1);return`${n>=10?Math.round(n):Math.round(n*10)/10} ${s[l]}`}export function fileHint(e){const t=fileRules(e),s=describeAccept(t.accept),n=t.multiple?`up to ${f(t.maxFiles)}, ${formatSize(t.maxSize)} each`:`up to ${formatSize(t.maxSize)}`;return s?`${s[0].toUpperCase()}${s.slice(1)} - ${n}`:`Any file - ${n}`}export function dropzoneOptions(e){const t=fileRules(e);return{accept:t.accept.length?t.accept.join(","):null,maxSize:t.maxSize,multiple:t.multiple,maxFiles:t.multiple?t.maxFiles:null,labels:{prompt:t.multiple?"Drop files here, paste, or click to browse":"Drop a file here, paste, or click to browse",hint:fileHint(e),required:"Please add a file",type:refusalMessage(e,{name:"{name}"},"type"),size:refusalMessage(e,{name:"{name}"},"size"),count:refusalMessage(e,{name:"{name}"},"count")}}}export function fileBlueprint(e,t){return{...t,type:"dropzone",formConfig:{...t.formConfig||{},...dropzoneOptions(e)}}}export function refusalMessage(e,t,s,n){const l=fileRules(e),o=t&&t.name||"That file";if(s==="total")return`${o} was not added: the files together would be more than ${formatSize(n)}.`;if(s==="size")return`${o} was not added: it is larger than ${formatSize(l.maxSize)}.`;if(s==="type"){const i=describeAccept(l.accept);return`${o} was not added: ${i?`only ${i} can be uploaded here`:"that type of file is not accepted"}.`}return s==="active"?`${o} was not added: web pages, SVG images and scripts cannot be uploaded.`:s==="kind"?`${o} was not added: that kind of file cannot be uploaded here.`:`${o} was not added: only ${f(l.maxFiles)} can be added here.`}function k(e){return Array.isArray(e)&&e.length>0&&typeof File<"u"&&e.every(t=>t instanceof File)}export function splitFiles(e,t,s){const n={...e||{}},l=new Map,o=new Set((t||[]).filter(i=>i&&i.type==="file"&&i.name).map(i=>i.name));for(const[i,r]of Object.entries(n))k(r)?(l.set(i,r),delete n[i]):o.has(i)&&(Array.isArray(r)||r==null)&&(n[i]="");for(const[i,r]of s||[])l.set(i,r||[]);for(const[i,r]of l)r.length?delete n[i]:(l.delete(i),(o.has(i)||i in n)&&(n[i]=""));return{values:n,files:l}}export function submitBody(e,t){if(![...(t||new Map).values()].some(l=>l&&l.length))return{body:JSON.stringify(e),headers:{"Content-Type":"application/json"},multipart:!1};const n=new FormData;for(const[l,o]of Object.entries(e))o==null||t.has(l)||n.append(l,typeof o=="object"?JSON.stringify(o):String(o));for(const[l,o]of t)for(const i of o||[])n.append(l,i,i.name);return{body:n,headers:{},multipart:!0}}export function captureFileFields(e,t){!e||!t||e.querySelectorAll('input[type="file"]').forEach(s=>{s.name&&t.set(s.name,Array.from(s.files||[]))})}export function restoreFileFields(e,t){!e||!t||!t.size||e.querySelectorAll('input[type="file"]').forEach(s=>{const n=t.get(s.name);if(!n||!n.length||s.files&&s.files.length)return;const l=b(s);if(l){l.addFiles(n);return}try{const o=new DataTransfer;n.forEach(i=>o.items.add(i)),s.files=o.files}catch{}})}function b(e){const t=globalThis.E;if(!t||typeof t.get!="function")return null;const s=e.closest("[data-dropzone-field]"),n=s&&t.get(s,"dropzone")||t.get(e,"dropzone");return n&&typeof n.addFiles=="function"?n:null}function p(e,t){const s=String(t&&t.name||"").replace(/\[\]$/,"");return(e||[]).find(n=>n&&n.name===s)||{name:s}}function S(e){let t=e.nextElementSibling;return(!t||!t.classList.contains("fb-file-refusal"))&&(t=document.createElement("p"),t.className="fb-file-refusal",t.hidden=!0,e.after(t)),t}export function submissionTooLarge(e){const t=e/1048576;return`The files sent are too large together (at most ${t>=1?`${Math.round(t*10)/10} MB`:`${Math.round(e/1024)} KB`} in one submission). Send fewer or smaller files.`}export function setFileLimits(e,t,s){e&&(e._fbFileLimits=t||null,s&&(e._fbRetained=s))}function u(e){return Number(e&&e._fbFileLimits&&e._fbFileLimits.maxTotalBytes)||0}function m(e){let t=0;for(const s of e||[])t+=Number(s&&s.size)||0;return t}export function totalTooLarge(e,t){const s=u(e);if(!s||!t)return null;let n=0;for(const l of t.values())n+=m(l);return n>s?submissionTooLarge(s):null}function $(e,t){const s=globalThis.E,n=a=>s&&typeof s.get=="function"?s.get(a,"dropzone"):null,l=new Set;e.querySelectorAll('.dm-dropzone, [data-dropzone-field], input[type="file"]').forEach(a=>{const c=n(a);c&&l.add(c)});const o=new Set,i=new Set;let r=0;for(const a of l)a.input&&(o.add(a.input),a.input.name&&i.add(a.input.name)),a.zone&&a.zone.querySelectorAll('input[type="file"]').forEach(c=>o.add(c)),a!==t&&(r+=m(a.getFiles()));e.querySelectorAll('input[type="file"]').forEach(a=>{o.has(a)||(a.name&&i.add(a.name),r+=m(a.files))});for(const[a,c]of e._fbRetained||[])i.has(a)||(r+=m(c));return r}export function wireFileFields(e,t){if(!e||e._fbFilesWired)return;e._fbFilesWired=!0;const s=new WeakMap,n=(o,i,r)=>{const a=S(o.zone),c=s.get(o.zone)||[];c.push(refusalMessage(p(t,o.input),i,r,u(e))),s.set(o.zone,c),a.textContent=c.join(" "),a.hidden=!1,o.zone.classList.add("is-invalid"),queueMicrotask(()=>s.delete(o.zone))};e.addEventListener("dm:dropzone:reject",o=>{const i=o.detail&&o.detail.instance,[r,a]=o.detail&&o.detail.args||[];i&&i.zone&&n(i,r,a)});const l=new Map;e.addEventListener("dm:dropzone:beforeadd",o=>{const i=o.detail&&o.detail.instance,[r]=o.detail&&o.detail.args||[];if(!i||!i.zone||!r)return;const a=r.file||r;if(isActiveFile(a)){o.preventDefault(),n(i,r,"active");return}if(isRefusedKind(a)){o.preventDefault(),n(i,r,"kind");return}const c=u(e);if(!c)return;const d=i.options&&i.options.multiple===!1,x=Number(r.size??a.size)||0,g=d?0:l.get(i)||0;if($(e,d?i:null)+g+x>c){o.preventDefault(),n(i,r,"total");return}d||(l.has(i)||queueMicrotask(()=>l.delete(i)),l.set(i,g+x))}),e.addEventListener("dm:dropzone:change",o=>{const i=o.detail&&o.detail.instance;i&&l.delete(i)}),e.addEventListener("dm:dropzone:add",o=>{const i=o.detail&&o.detail.instance;if(!i||!i.zone||s.get(i.zone))return;const r=i.zone.nextElementSibling;r&&r.classList.contains("fb-file-refusal")&&(r.hidden=!0,r.textContent="")}),e.addEventListener("dm:dropzone:change",o=>{const i=o.detail&&o.detail.instance;if(!i||!i.input)return;i.input.dispatchEvent(new Event("input",{bubbles:!0}));const r=i.input.closest(".fb-field-invalid");r&&i.getFiles().length&&r.classList.remove("fb-field-invalid")})}export function enhanceFileInputs(e,t){const s=globalThis.E;!e||!s||typeof s.dropzone!="function"||(e.querySelectorAll('input[type="file"]').forEach(n=>{b(n)||s.dropzone(n,dropzoneOptions(p(t,n)))}),wireFileFields(e,t))}export function checkFileInputs(e,t){const s=[];return e&&e.querySelectorAll('input[type="file"]').forEach(n=>{const l=p(t,n),o=Array.from(n.files||[]),i=countRefusal(l,o.length);i&&s.push(i),o.forEach(r=>{const a=fileRefusal(l,r);a&&s.push(a)})}),s}export function destroyDropzones(e){const t=globalThis.E;!e||!t||typeof t.get!="function"||e.querySelectorAll('.dm-dropzone, input[type="file"]').forEach(s=>{const n=t.get(s,"dropzone");n&&typeof n.destroy=="function"&&n.destroy()})}
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Write REFUSED_EXTENSIONS into public/js/form-files.mjs: the extensions /media/ would
4
+ * serve as a type no public form may collect (server/services/mediaTypes.js). The page
5
+ * refuses them as they are added; the server decides by the same rule. Run it when the
6
+ * rule or the `mime` copy @fastify/send uses changes - tests/forms/form-file-types.test.js
7
+ * fails until it is.
8
+ *
9
+ * node scripts/build-form-file-types.mjs
10
+ */
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import {fileURLToPath} from 'url';
14
+ import {refusedExtensions} from '../server/services/mediaTypes.js';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const FILE = path.join(ROOT, 'public', 'js', 'form-files.mjs');
18
+ export const BEGIN = '// BEGIN refused-extensions (scripts/build-form-file-types.mjs writes this block)';
19
+ export const END = '// END refused-extensions';
20
+
21
+ /** The block as the script writes it. */
22
+ export function refusedBlock(exts = refusedExtensions()) {
23
+ const lines = [];
24
+ let line = '';
25
+ for (const ext of exts) {
26
+ if (line && line.length + ext.length + 1 > 100) {
27
+ lines.push(line);
28
+ line = '';
29
+ }
30
+ line += (line ? ' ' : '') + ext;
31
+ }
32
+ if (line) lines.push(line);
33
+ return [BEGIN, 'const REFUSED_EXTENSIONS = new Set((', ...lines.map(l => ` '${l} ' +`), " '').trim().split(' '));", END].join('\n');
34
+ }
35
+
36
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
37
+ const src = fs.readFileSync(FILE, 'utf8');
38
+ const a = src.indexOf(BEGIN);
39
+ const b = src.indexOf(END);
40
+ if (a < 0 || b < a) throw new Error(`${FILE} has no refused-extensions block`);
41
+ fs.writeFileSync(FILE, src.slice(0, a) + refusedBlock() + src.slice(b + END.length));
42
+ console.log(`REFUSED_EXTENSIONS written (${refusedExtensions().length} extensions).`);
43
+ }
@@ -1,3 +1,5 @@
1
+ import {packDefinition, unpackDefinition, sendDefinition} from '../../services/definition-transfer.js';
2
+ import {importFormDefinition} from '../../services/form-transfer.js';
1
3
  /**
2
4
  * Core Forms API Routes
3
5
  * REST endpoints for form CRUD, public rendering, and submission handling.
@@ -47,7 +49,7 @@ import {authenticate, requireAdmin, requirePermission} from '../../middleware/au
47
49
  import {hooks} from '../../services/hooks.js';
48
50
  import {pluginDenialFor} from '../../services/collectionAccess.js';
49
51
  import {deleteMedia, saveMedia} from '../../services/content.js';
50
- import {servedType} from '../../services/mediaTypes.js';
52
+ import {formMayCollect, servedType} from '../../services/mediaTypes.js';
51
53
  import {v4 as uuidv4} from 'uuid';
52
54
  import {ago, clip, plural} from '../../services/sidebarBadges.js';
53
55
  import {safeFetch} from '../../services/outbound.js';
@@ -117,18 +119,10 @@ function safeFilename(raw) {
117
119
  return `${uuidv4().slice(0, 8)}-${cleanUploadName(raw)}`;
118
120
  }
119
121
 
120
- /**
121
- * The kinds of file a form may collect, by the type /media/ will serve the stored name as:
122
- * images (not SVG), audio, video, PDF, Office and OpenDocument, plain text and CSV,
123
- * archives. A name with no known type is served as a download. Anything else - above all
124
- * anything a browser would render as a page on this site's address - is refused.
125
- */
126
- const SERVABLE = /^(image\/(?!svg)[\w.+-]+|audio\/[\w.+-]+|video\/[\w.+-]+|text\/(plain|csv)|application\/(pdf|rtf|zip|x-zip-compressed|gzip|x-gzip|x-tar|x-7z-compressed|x-rar-compressed|vnd\.rar|msword|vnd\.ms-[\w.+-]+|vnd\.openxmlformats-officedocument\.[\w.+-]+|vnd\.oasis\.opendocument\.[\w.+-]+|epub\+zip|octet-stream))$/i;
127
-
128
122
  /** Why a stored name would be served as something a form may not collect, or null. */
129
123
  function servedTypeRefusal(fieldCfg, filename) {
130
124
  const served = servedType(cleanUploadName(filename));
131
- if (!served || SERVABLE.test(served)) return null;
125
+ if (!served || formMayCollect(served)) return null;
132
126
  const label = fieldCfg.label || fieldCfg.name;
133
127
  return `"${label}" file type "${served}" cannot be uploaded here.`;
134
128
  }
@@ -275,7 +269,7 @@ async function saveFormFiles(pending, body, keep) {
275
269
  }
276
270
  const refs = Array.isArray(body[name]) ? body[name] : [body[name]];
277
271
  for (let i = 0; i < files.length; i++) {
278
- const saved = await saveMedia(safeFilename(files[i].filename), files[i].buffer);
272
+ const saved = await saveNewMedia(files[i]);
279
273
  written.push(saved.name);
280
274
  refs[i].url = saved.url;
281
275
  }
@@ -287,6 +281,20 @@ async function saveFormFiles(pending, body, keep) {
287
281
  return written;
288
282
  }
289
283
 
284
+ /**
285
+ * Write one visitor file under a new name. Never over another file: a clash of the random
286
+ * prefix picks another, and a write that fails part way removes what it wrote.
287
+ */
288
+ async function saveNewMedia(file) {
289
+ for (let tries = 0; ; tries++) {
290
+ try {
291
+ return await saveMedia(safeFilename(file.filename), file.buffer, {exclusive: true});
292
+ } catch (err) {
293
+ if (err.code !== 'EEXIST' || tries >= 4) throw err;
294
+ }
295
+ }
296
+ }
297
+
290
298
  /** Remove a submission's written files again (it was not stored after all). Never throws. */
291
299
  async function removeFormFiles(names) {
292
300
  for (const name of names || []) {
@@ -493,6 +501,21 @@ export async function formsRoutes(fastify) {
493
501
  const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
494
502
  const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
495
503
 
504
+ fastify.get('/forms/:slug/export', canRead, async (request, reply) => {
505
+ let form;
506
+ try { form = await readForm(request.params.slug); }
507
+ catch { return reply.code(404).send({error: 'Form not found.'}); }
508
+ const {canSeeArtefact} = await import('../../services/projects.js');
509
+ if (!canSeeArtefact(request.user, form)) return reply.code(403).send({error: 'Access denied for this project'});
510
+ return sendDefinition(reply, packDefinition('form', form));
511
+ });
512
+ fastify.post('/forms/import', canCreate, async (request, reply) => {
513
+ try {
514
+ const form = await importFormDefinition(unpackDefinition('form', request.body));
515
+ return reply.code(201).send(form);
516
+ } catch (err) { return reply.code(400).send({error: err.message}); }
517
+ });
518
+
496
519
  // -----------------------------------------------------------------------
497
520
  // GET /forms - list all form definitions with submission counts
498
521
  // -----------------------------------------------------------------------
@@ -414,12 +414,23 @@ export async function listMedia() {
414
414
  *
415
415
  * @param {string} filename
416
416
  * @param {Buffer} buffer
417
+ * @param {{exclusive?: boolean}} [opts] - exclusive: refuse (EEXIST) rather than replace a file
417
418
  * @returns {Promise<object>}
418
419
  */
419
- export async function saveMedia(filename, buffer) {
420
+ export async function saveMedia(filename, buffer, {exclusive = false} = {}) {
420
421
  await fs.mkdir(MEDIA_DIR, { recursive: true });
421
422
  const filePath = path.join(MEDIA_DIR, filename);
422
- await fs.writeFile(filePath, buffer);
423
+ if (exclusive) {
424
+ // Only ever a new file, so one that fails part way (a full disk) is ours to remove.
425
+ try {
426
+ await fs.writeFile(filePath, buffer, {flag: 'wx'});
427
+ } catch (err) {
428
+ if (err.code !== 'EEXIST') await fs.rm(filePath, {force: true}).catch(() => {});
429
+ throw err;
430
+ }
431
+ } else {
432
+ await fs.writeFile(filePath, buffer);
433
+ }
423
434
  hooks.emit('content:mediaUploaded', {filename});
424
435
  return { name: filename, url: `/media/${filename}` };
425
436
  }
@@ -0,0 +1,32 @@
1
+ /** Versioned, definition-only transfers. Runtime entries and audience IDs stay on their site. */
2
+ const KEYS = {
3
+ form: ['slug', 'title', 'description', 'fields', 'settings', 'actions'],
4
+ survey: ['slug', 'title', 'description', 'formSlug', 'mode', 'reminderAfterDays', 'intro', 'footer', 'thankYou', 'inviteEmail'],
5
+ campaign: ['slug', 'title', 'description', 'message', 'linkUrl', 'endsAt', 'offer']
6
+ };
7
+ export function definition(kind, value) {
8
+ if (!KEYS[kind] || !value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid definition.');
9
+ if (typeof value.title !== 'string' || !value.title.trim()) throw new Error('A definition needs a title.');
10
+ if (typeof value.slug !== 'string' || value.slug.length > 80 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.slug)) throw new Error('Invalid definition slug.');
11
+ if (kind === 'form') {
12
+ if (!Array.isArray(value.fields) || value.fields.some(f => !f || typeof f !== 'object' || Array.isArray(f) || typeof f.type !== 'string')) throw new Error('Invalid form fields.');
13
+ for (const key of ['settings', 'actions']) if (value[key] != null && (typeof value[key] !== 'object' || Array.isArray(value[key]))) throw new Error(`Invalid form ${key}.`);
14
+ }
15
+ return structuredClone(Object.fromEntries(KEYS[kind].filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]])));
16
+ }
17
+ export function packDefinition(kind, value, extra = {}) {
18
+ return {format: 'domma-definition', version: 1, kind, definition: definition(kind, value), ...extra};
19
+ }
20
+ export function unpackDefinition(kind, bundle) {
21
+ if (bundle?.format !== 'domma-definition' || bundle.version !== 1 || bundle.kind !== kind) throw new Error(`Choose a Domma ${kind} export (version 1).`);
22
+ return definition(kind, bundle.definition);
23
+ }
24
+ export function unusedSlug(slug, existing) {
25
+ const used = new Set(existing);
26
+ let next = slug;
27
+ for (let i = 2; used.has(next); i++) next = `${slug.slice(0, 70).replace(/-$/, '')}-${i}`;
28
+ return next;
29
+ }
30
+ export function sendDefinition(reply, bundle) {
31
+ return reply.header('Content-Disposition', `attachment; filename="${bundle.kind}-${bundle.definition.slug}.json"`).send(bundle);
32
+ }
@@ -0,0 +1,23 @@
1
+ import {createForm, deleteForm, ensureCollectionForForm, listForms} from './forms.js';
2
+ import {getCollection} from './collections.js';
3
+ import {definition, unusedSlug} from './definition-transfer.js';
4
+
5
+ /** Imported forms always get their own private submission collection. */
6
+ export async function importFormDefinition(input) {
7
+ const data = definition('form', input);
8
+ const used = (await listForms()).map(f => f.slug);
9
+ data.slug = unusedSlug(data.slug, used);
10
+ while (await getCollection(data.slug)) {
11
+ used.push(data.slug);
12
+ data.slug = unusedSlug(input.slug, used);
13
+ }
14
+ data.actions = {...data.actions, collection: {enabled: true, slug: data.slug}};
15
+ const form = await createForm(data);
16
+ try {
17
+ await ensureCollectionForForm(form);
18
+ } catch (err) {
19
+ await deleteForm(form.slug);
20
+ throw err;
21
+ }
22
+ return form;
23
+ }
@@ -4,6 +4,7 @@
4
4
  * Submissions are stored exclusively in Collections (slug matching form slug).
5
5
  */
6
6
  import fs from 'fs/promises';
7
+ import {watch as fsWatch} from 'fs';
7
8
  import path from 'path';
8
9
  import {fileURLToPath} from 'url';
9
10
  import * as cache from './cache/index.js';
@@ -39,7 +40,48 @@ export function maxSubmissionBytes() {
39
40
  */
40
41
  export function publicFileLimits(form) {
41
42
  const hasFiles = (form?.fields || []).some(f => f && f.type === 'file');
42
- return hasFiles ? {...form, fileLimits: {maxTotalBytes: maxSubmissionBytes()}} : form;
43
+ if (!hasFiles) return form;
44
+ const cap = maxSubmissionBytes();
45
+ watchFileLimits(cap);
46
+ return {...form, fileLimits: {maxTotalBytes: cap}};
47
+ }
48
+
49
+ /** The cache tag on every page that drew a form's file cap (markdown.js adds it). */
50
+ export const FILE_LIMITS_TAG = 'forms:file-limits';
51
+
52
+ let limitsWatch = null; // {cap, watcher} once a page has drawn a cap; cap = the last one seen in server.json
53
+
54
+ /**
55
+ * Keep cached pages' file cap in step with server.json. Nothing in the admin writes that
56
+ * file, so a change there (by hand, or by the Manager) has no save to hang an
57
+ * invalidation on: watch the config folder, and when the cap changes, drop the pages that
58
+ * carry it. Only the watcher moves `cap` - a page drawn with the new cap before the event
59
+ * arrives must not hide the change from the pages cached with the old one. The folder,
60
+ * not the file: an editor's save replaces the file, which ends a watch on it. A watch that
61
+ * ends (the folder itself replaced) is armed again by the next page that draws a cap.
62
+ *
63
+ * @param {number} cap - the cap just drawn
64
+ */
65
+ function watchFileLimits(cap) {
66
+ if (limitsWatch) return;
67
+ limitsWatch = {cap, watcher: null};
68
+ try {
69
+ const watcher = fsWatch(path.resolve('config'), (_event, name) => {
70
+ if (name && String(name) !== 'server.json') return;
71
+ const now = maxSubmissionBytes();
72
+ if (now === limitsWatch?.cap) return;
73
+ if (limitsWatch) limitsWatch.cap = now;
74
+ cache.invalidateTags([FILE_LIMITS_TAG]).catch(() => {});
75
+ });
76
+ const disarm = () => {
77
+ if (limitsWatch?.watcher === watcher) limitsWatch = null;
78
+ try { watcher.close(); } catch { /* closed */ }
79
+ };
80
+ watcher.on('error', disarm);
81
+ watcher.on('close', () => { if (limitsWatch?.watcher === watcher) limitsWatch = null; });
82
+ watcher.unref?.();
83
+ limitsWatch.watcher = watcher;
84
+ } catch { /* no watching here: a restart or a form save still redraws */ }
43
85
  }
44
86
 
45
87
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -23,7 +23,7 @@ import {buildTextStyle} from '../../public/js/text-style.mjs';
23
23
  import {buildScribeStyle} from '../../public/js/scribe-style.mjs';
24
24
  import {CHARACTERS, resolveCharacterSize} from '../../public/js/celebrations/character-catalog.mjs';
25
25
  import {publicButtonSettings} from '../../public/js/form-submit.mjs';
26
- import {publicFileLimits} from './forms.js';
26
+ import {FILE_LIMITS_TAG, publicFileLimits} from './forms.js';
27
27
  import {applyShortcodeExpiry} from './shortcodeExpiry.js';
28
28
 
29
29
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
@@ -4462,7 +4462,9 @@ async function processFormBlocks(markdown, tagSet) {
4462
4462
  const {actions: _actions, ...publicForm} = form;
4463
4463
  // A Cancel link that is not safe to draw stays out of the page source.
4464
4464
  if (publicForm.settings) publicForm.settings = publicButtonSettings(publicForm.settings);
4465
- const encoded = Buffer.from(JSON.stringify(publicFileLimits(publicForm))).toString('base64');
4465
+ const drawn = publicFileLimits(publicForm);
4466
+ if (drawn.fileLimits) tagSet?.add(FILE_LIMITS_TAG);
4467
+ const encoded = Buffer.from(JSON.stringify(drawn)).toString('base64');
4466
4468
  const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
4467
4469
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
4468
4470
  replacement = `<div class="cms-form-embed${extraClass}" data-form-inline="${escapeAttr(encoded)}"${idAttr}>${formSummaryHtml(form)}</div>`;
@@ -20,3 +20,25 @@ export function servedType(name) {
20
20
  export function isActiveType(type) {
21
21
  return /^(text\/html|text\/xml|application\/xml|image\/svg)|\+xml$|javascript|ecmascript/i.test(String(type || ''));
22
22
  }
23
+
24
+ /**
25
+ * The kinds of file a form may collect, by the type /media/ will serve the stored name as:
26
+ * images (not SVG), audio, video, PDF, Office and OpenDocument, plain text and CSV,
27
+ * archives. A name with no known type is served as a download. Anything else - above all
28
+ * anything a browser would render as a page on this site's address - is refused.
29
+ */
30
+ const FORM_SERVABLE = /^(image\/(?!svg)[\w.+-]+|audio\/[\w.+-]+|video\/[\w.+-]+|text\/(plain|csv)|application\/(pdf|rtf|zip|x-zip-compressed|gzip|x-gzip|x-tar|x-7z-compressed|x-rar-compressed|vnd\.rar|msword|vnd\.ms-[\w.+-]+|vnd\.openxmlformats-officedocument\.[\w.+-]+|vnd\.oasis\.opendocument\.[\w.+-]+|epub\+zip|octet-stream))$/i;
31
+
32
+ /** Whether a public form may collect a file /media/ would serve as this type. */
33
+ export function formMayCollect(type) {
34
+ return FORM_SERVABLE.test(String(type || ''));
35
+ }
36
+
37
+ /**
38
+ * The extensions /media/ would serve as a type no form may collect, sorted - what
39
+ * public/js/form-files.mjs carries as REFUSED_EXTENSIONS so the page refuses them as they
40
+ * are added (scripts/build-form-file-types.mjs writes it; a test checks it).
41
+ */
42
+ export function refusedExtensions() {
43
+ return Object.entries(mime._types).filter(([, type]) => !formMayCollect(type)).map(([ext]) => ext).sort();
44
+ }
@@ -8,6 +8,7 @@ import {fileURLToPath} from 'url';
8
8
  import {getConfig} from '../config.js';
9
9
  import {getInjectionSnippets} from './plugins.js';
10
10
  import {applyTransforms} from './hooks.js';
11
+ import {FILE_LIMITS_TAG} from './forms.js';
11
12
  import {contentsPaint, readContentsOverride, resolveContents} from './pageContents.js';
12
13
  import {applyMenuOverride, hasThemeSwitcherItem, resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from './menus.js';
13
14
  import {buildMenuNav} from './menuRender.js';
@@ -172,7 +173,13 @@ export function footerBlockNames(site) {
172
173
  * and expiring shortcodes are never run - those stages come before [block] - so
173
174
  * the block names are the whole of what a footer depends on beyond `site`.)
174
175
  */
175
- export const footerBlockCacheTags = (site) => footerBlockNames(site).map(n => `block:${n}`);
176
+ // A footer block's own shortcode tags are not carried out of its render, and a [form] in
177
+ // one draws the file cap: every page with footer blocks also carries the cap's tag (it is
178
+ // invalidated only when server.json changes the cap).
179
+ export const footerBlockCacheTags = (site) => {
180
+ const names = footerBlockNames(site);
181
+ return names.length ? [...names.map(n => `block:${n}`), FILE_LIMITS_TAG] : [];
182
+ };
176
183
 
177
184
  /**
178
185
  * The blocks in the footer (site.json footer.blocks - Site Settings > Footer,