domma-cms 1.10.18 → 1.10.19
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 +8 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/docs/configuration.md +1 -1
- package/package.json +2 -2
- package/public/js/form-files.mjs +1 -1
- package/scripts/build-form-file-types.mjs +43 -0
- package/server/routes/api/forms.js +17 -11
- package/server/services/content.js +13 -2
- package/server/services/forms.js +43 -1
- package/server/services/markdown.js +4 -2
- package/server/services/mediaTypes.js +22 -0
- package/server/services/renderer.js +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.19] - 2026-10-25
|
|
4
|
+
|
|
5
|
+
- **Built against domma-js 0.58.2** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.58.2).
|
|
6
|
+
- **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`).
|
|
7
|
+
- **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.
|
|
8
|
+
- **The admin command palette's recent picks from "Open page…", "Open collection…" and "Open form…" survive a reload** (domma-js 0.58.2).
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
3
11
|
## [1.10.18] - 2026-10-25
|
|
4
12
|
|
|
5
13
|
- **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.
|
|
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-
|
|
6
|
-
* Commit:
|
|
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.
|
|
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-
|
|
6
|
-
* Commit:
|
|
5
|
+
* Built: 2026-10-25T15:33:15.786Z
|
|
6
|
+
* Commit: e547926
|
|
7
7
|
*
|
|
8
8
|
* Requires: domma.min.js
|
|
9
9
|
*/
|
package/docs/configuration.md
CHANGED
|
@@ -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]`
|
|
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` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.19",
|
|
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.
|
|
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",
|
package/public/js/form-files.mjs
CHANGED
|
@@ -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
|
+
}
|
|
@@ -47,7 +47,7 @@ import {authenticate, requireAdmin, requirePermission} from '../../middleware/au
|
|
|
47
47
|
import {hooks} from '../../services/hooks.js';
|
|
48
48
|
import {pluginDenialFor} from '../../services/collectionAccess.js';
|
|
49
49
|
import {deleteMedia, saveMedia} from '../../services/content.js';
|
|
50
|
-
import {servedType} from '../../services/mediaTypes.js';
|
|
50
|
+
import {formMayCollect, servedType} from '../../services/mediaTypes.js';
|
|
51
51
|
import {v4 as uuidv4} from 'uuid';
|
|
52
52
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
53
53
|
import {safeFetch} from '../../services/outbound.js';
|
|
@@ -117,18 +117,10 @@ function safeFilename(raw) {
|
|
|
117
117
|
return `${uuidv4().slice(0, 8)}-${cleanUploadName(raw)}`;
|
|
118
118
|
}
|
|
119
119
|
|
|
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
120
|
/** Why a stored name would be served as something a form may not collect, or null. */
|
|
129
121
|
function servedTypeRefusal(fieldCfg, filename) {
|
|
130
122
|
const served = servedType(cleanUploadName(filename));
|
|
131
|
-
if (!served ||
|
|
123
|
+
if (!served || formMayCollect(served)) return null;
|
|
132
124
|
const label = fieldCfg.label || fieldCfg.name;
|
|
133
125
|
return `"${label}" file type "${served}" cannot be uploaded here.`;
|
|
134
126
|
}
|
|
@@ -275,7 +267,7 @@ async function saveFormFiles(pending, body, keep) {
|
|
|
275
267
|
}
|
|
276
268
|
const refs = Array.isArray(body[name]) ? body[name] : [body[name]];
|
|
277
269
|
for (let i = 0; i < files.length; i++) {
|
|
278
|
-
const saved = await
|
|
270
|
+
const saved = await saveNewMedia(files[i]);
|
|
279
271
|
written.push(saved.name);
|
|
280
272
|
refs[i].url = saved.url;
|
|
281
273
|
}
|
|
@@ -287,6 +279,20 @@ async function saveFormFiles(pending, body, keep) {
|
|
|
287
279
|
return written;
|
|
288
280
|
}
|
|
289
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Write one visitor file under a new name. Never over another file: a clash of the random
|
|
284
|
+
* prefix picks another, and a write that fails part way removes what it wrote.
|
|
285
|
+
*/
|
|
286
|
+
async function saveNewMedia(file) {
|
|
287
|
+
for (let tries = 0; ; tries++) {
|
|
288
|
+
try {
|
|
289
|
+
return await saveMedia(safeFilename(file.filename), file.buffer, {exclusive: true});
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (err.code !== 'EEXIST' || tries >= 4) throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
290
296
|
/** Remove a submission's written files again (it was not stored after all). Never throws. */
|
|
291
297
|
async function removeFormFiles(names) {
|
|
292
298
|
for (const name of names || []) {
|
|
@@ -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
|
-
|
|
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
|
}
|
package/server/services/forms.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|