@stacksjs/forms 0.74.1 → 0.74.3
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/dist/index.d.ts +9 -0
- package/dist/index.js +1 -1
- package/dist/uploads.d.ts +70 -0
- package/dist/uploads.js +1 -0
- package/dist/validate.js +1 -1
- package/package.json +7 -7
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type {
|
|
|
13
13
|
SubmissionErrors,
|
|
14
14
|
ValidateSubmissionResult,
|
|
15
15
|
} from './types';
|
|
16
|
+
export type { UploadLimits, UploadRejection } from './uploads';
|
|
16
17
|
export { evaluateConditions, visibleFields } from './conditions';
|
|
17
18
|
export { createForm, loadFormByHandle, toCreateFields } from './create';
|
|
18
19
|
export { loadFormByUuid, publicDefinition } from './definition';
|
|
@@ -23,4 +24,12 @@ export {
|
|
|
23
24
|
fetchSubmissions,
|
|
24
25
|
submitForm,
|
|
25
26
|
} from './submissions';
|
|
27
|
+
export {
|
|
28
|
+
checkUpload,
|
|
29
|
+
extensionOf,
|
|
30
|
+
fileFieldNamed,
|
|
31
|
+
formUploadPrefix,
|
|
32
|
+
isOwnedUploadPath,
|
|
33
|
+
resolveUploadLimits,
|
|
34
|
+
} from './uploads';
|
|
26
35
|
export { computeAmountCents, validateSubmission } from './validate';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{evaluateConditions,visibleFields}from"./conditions";export{createForm,loadFormByHandle,toCreateFields}from"./create";export{loadFormByUuid,publicDefinition}from"./definition";export{dispatchSubmissionNotifications}from"./notifications";export{completeSubmissionPayment,exportSubmissionsCsv,fetchSubmissions,submitForm}from"./submissions";export{computeAmountCents,validateSubmission}from"./validate";
|
|
1
|
+
export{evaluateConditions,visibleFields}from"./conditions";export{createForm,loadFormByHandle,toCreateFields}from"./create";export{loadFormByUuid,publicDefinition}from"./definition";export{dispatchSubmissionNotifications}from"./notifications";export{completeSubmissionPayment,exportSubmissionsCsv,fetchSubmissions,submitForm}from"./submissions";export{checkUpload,extensionOf,fileFieldNamed,formUploadPrefix,isOwnedUploadPath,resolveUploadLimits}from"./uploads";export{computeAmountCents,validateSubmission}from"./validate";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { FormDefinition, FormFieldDefinition } from './types';
|
|
2
|
+
/** Lowercase extension of a filename, without the dot. `''` when it has none. */
|
|
3
|
+
export declare function extensionOf(filename: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* The ceilings for one field: the field's own options win over the app's
|
|
6
|
+
* defaults, which win over the framework's.
|
|
7
|
+
*
|
|
8
|
+
* A field saying `accept: ['pdf']` on a form whose config allows images is
|
|
9
|
+
* asking for something narrower, and narrower is the answer that cannot
|
|
10
|
+
* surprise anyone. The reverse — a field widening past the app's config — is
|
|
11
|
+
* NOT honoured: the config is the app's ceiling and a form builder should not
|
|
12
|
+
* be able to raise it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveUploadLimits(field: FormFieldDefinition): UploadLimits;
|
|
15
|
+
/**
|
|
16
|
+
* The storage prefix that owns a form's uploads.
|
|
17
|
+
*
|
|
18
|
+
* `validate.ts` has always said "the prefix pins it to this form's upload
|
|
19
|
+
* area, so a submission cannot reference someone else's file" — while checking
|
|
20
|
+
* only the value's length. This is the prefix that comment was describing, and
|
|
21
|
+
* `checkField` now actually tests it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function formUploadPrefix(form: Pick<FormDefinition, 'uuid'>): string;
|
|
24
|
+
/** Is this stored path inside the form's own upload area? */
|
|
25
|
+
export declare function isOwnedUploadPath(form: Pick<FormDefinition, 'uuid'>, path: unknown): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Check one incoming file against a field's ceilings.
|
|
28
|
+
*
|
|
29
|
+
* Returns null when it is acceptable. The size check comes first: an oversized
|
|
30
|
+
* file of a disallowed type should report the size, because that is the one
|
|
31
|
+
* the person cannot fix by renaming.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkUpload(field: FormFieldDefinition, file: { name?: string, size?: number }, limits?: UploadLimits): UploadRejection | null;
|
|
34
|
+
/** The `file` field with this name on a form, or null. */
|
|
35
|
+
export declare function fileFieldNamed(form: FormDefinition, name: unknown): FormFieldDefinition | null;
|
|
36
|
+
/**
|
|
37
|
+
* File uploads for a form's `file` fields.
|
|
38
|
+
*
|
|
39
|
+
* `config/forms.ts` has declared `uploads: { disk, maxSizeMb, allowedTypes }`
|
|
40
|
+
* since the bundle shipped, fully typed and documented — and nothing read any
|
|
41
|
+
* of it. An app could set `maxSizeMb: 2` and `allowedTypes: ['pdf']` and
|
|
42
|
+
* neither was ever applied, because the only check on a `file` field was that
|
|
43
|
+
* its value is a string under 1024 characters. stacksjs/stacks#2406.
|
|
44
|
+
*
|
|
45
|
+
* Enforced server-side rather than through a presigned policy, deliberately.
|
|
46
|
+
* A presign lets the browser upload straight to storage, which means the size
|
|
47
|
+
* ceiling has to be expressed in the policy and the type ceiling cannot really
|
|
48
|
+
* be expressed at all — S3 checks the `Content-Type` the CLIENT declared. Here
|
|
49
|
+
* the server has the bytes, so both limits are checked against what actually
|
|
50
|
+
* arrived, and the same code path works on a local disk. A public form is not
|
|
51
|
+
* a bulk-upload surface; 10MB through the app is not the bottleneck.
|
|
52
|
+
*/
|
|
53
|
+
/** Resolved ceilings for one file field. */
|
|
54
|
+
export declare interface UploadLimits {
|
|
55
|
+
disk?: string
|
|
56
|
+
maxBytes: number
|
|
57
|
+
allowedTypes: string[]
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Why an upload was refused, in the shape the endpoint answers with.
|
|
61
|
+
*
|
|
62
|
+
* The status is the literal union rather than `number` so the route can hand
|
|
63
|
+
* it straight to `response.json`, whose `ResponseStatus` is a union of literal
|
|
64
|
+
* codes. A widened `number` there forces a cast at the call site, and a cast
|
|
65
|
+
* is how a status nobody intended eventually gets returned.
|
|
66
|
+
*/
|
|
67
|
+
export declare interface UploadRejection {
|
|
68
|
+
status: 413 | 422
|
|
69
|
+
message: string
|
|
70
|
+
}
|
package/dist/uploads.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{config}from"@stacksjs/config";const DEFAULT_MAX_SIZE_MB=10,DEFAULT_ALLOWED_TYPES=["pdf","png","jpg","jpeg","webp","doc","docx"];function uploadConfig(){return config.forms?.uploads??{}}export function extensionOf(filename){const base=filename.split("/").pop()??"",dot=base.lastIndexOf(".");return dot>0?base.slice(dot+1).toLowerCase():""}export function resolveUploadLimits(field){const appConfig=uploadConfig(),appMaxMb=appConfig.maxSizeMb??DEFAULT_MAX_SIZE_MB,appTypes=(appConfig.allowedTypes??DEFAULT_ALLOWED_TYPES).map((type)=>type.toLowerCase().replace(/^\./,"")),fieldMaxMb=field.options?.maxSizeMb,maxMb=typeof fieldMaxMb==="number"&&fieldMaxMb>0?Math.min(fieldMaxMb,appMaxMb):appMaxMb,fieldTypes=field.options?.accept?.map((type)=>type.toLowerCase().replace(/^\./,"")),allowedTypes=fieldTypes?.length?fieldTypes.filter((type)=>appTypes.includes(type)):appTypes;return{disk:appConfig.disk,maxBytes:Math.round(maxMb*1024*1024),allowedTypes}}export function formUploadPrefix(form){return`forms/${form.uuid}`}export function isOwnedUploadPath(form,path){if(typeof path!=="string"||path.length===0||path.length>1024)return!1;if(path.includes("..")||path.startsWith("/")||path.includes("://")||path.includes("\\"))return!1;return path.startsWith(`${formUploadPrefix(form)}/`)}export function checkUpload(field,file,limits=resolveUploadLimits(field)){const size=Number(file.size??0);if(!Number.isFinite(size)||size<=0)return{status:422,message:`${field.label} upload is empty.`};if(size>limits.maxBytes){const mb=(limits.maxBytes/1048576).toFixed(limits.maxBytes%1048576===0?0:1);return{status:413,message:`${field.label} must be ${mb} MB or smaller.`}}const extension=extensionOf(String(file.name??""));if(limits.allowedTypes.length>0&&!limits.allowedTypes.includes(extension))return{status:422,message:`${field.label} must be one of: ${limits.allowedTypes.join(", ")}.`};return null}export function fileFieldNamed(form,name){if(typeof name!=="string"||!name)return null;const field=form.fields.find((candidate)=>candidate.name===name);return field&&field.type==="file"?field:null}
|
package/dist/validate.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{visibleFields}from"./conditions";const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/,PHONE_RE=/^[\d\s()+.\-ext]{7,32}$/i;function isBlank(value){return value===void 0||value===null||typeof value==="string"&&value.trim()===""}function checkField(field,value){const options=field.options??{};if(isBlank(value))return field.required?`${field.label} is required`:null;switch(field.type){case"text":case"textarea":{if(typeof value!=="string")return`${field.label} must be text`;const max=options.max??(field.type==="text"?500:1e4);if(value.length>max)return`${field.label} must be at most ${max} characters`;if(options.min&&value.length<options.min)return`${field.label} must be at least ${options.min} characters`;return null}case"email":return typeof value==="string"&&EMAIL_RE.test(value)&&value.length<=255?null:`${field.label} must be a valid email address`;case"phone":return typeof value==="string"&&PHONE_RE.test(value)?null:`${field.label} must be a valid phone number`;case"select":case"radio":{const choices=(options.choices??[]).map((choice)=>choice.value);return typeof value==="string"&&choices.includes(value)?null:`${field.label} must be one of the listed choices`}case"checkbox":return typeof value==="boolean"||value==="true"||value==="false"?null:`${field.label} must be a yes or no`;case"date":{if(typeof value!=="string"||Number.isNaN(Date.parse(value)))return`${field.label} must be a valid date`;return null}case"file":return
|
|
1
|
+
import{visibleFields}from"./conditions";import{isOwnedUploadPath}from"./uploads";const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/,PHONE_RE=/^[\d\s()+.\-ext]{7,32}$/i;function isBlank(value){return value===void 0||value===null||typeof value==="string"&&value.trim()===""}function checkField(field,value,form){const options=field.options??{};if(isBlank(value))return field.required?`${field.label} is required`:null;switch(field.type){case"text":case"textarea":{if(typeof value!=="string")return`${field.label} must be text`;const max=options.max??(field.type==="text"?500:1e4);if(value.length>max)return`${field.label} must be at most ${max} characters`;if(options.min&&value.length<options.min)return`${field.label} must be at least ${options.min} characters`;return null}case"email":return typeof value==="string"&&EMAIL_RE.test(value)&&value.length<=255?null:`${field.label} must be a valid email address`;case"phone":return typeof value==="string"&&PHONE_RE.test(value)?null:`${field.label} must be a valid phone number`;case"select":case"radio":{const choices=(options.choices??[]).map((choice)=>choice.value);return typeof value==="string"&&choices.includes(value)?null:`${field.label} must be one of the listed choices`}case"checkbox":return typeof value==="boolean"||value==="true"||value==="false"?null:`${field.label} must be a yes or no`;case"date":{if(typeof value!=="string"||Number.isNaN(Date.parse(value)))return`${field.label} must be a valid date`;return null}case"file":return isOwnedUploadPath(form,value)?null:`${field.label} upload is invalid`;case"currency":{const cents=Number(value);if(!Number.isInteger(cents)||cents<0)return`${field.label} must be a whole amount`;if(options.min!==void 0&¢s<options.min)return`${field.label} must be at least ${options.min}`;if(options.max!==void 0&¢s>options.max)return`${field.label} must be at most ${options.max}`;return null}case"section_break":return null;default:return`${field.label} has an unknown type`}}function coerce(field,value){if(field.type==="checkbox")return value===!0||value==="true";if(field.type==="currency")return Number(value);return value}function pickEmail(form,fields,values){const named=form.settings.emailField,field=named?fields.find((candidate)=>candidate.name===named):fields.find((candidate)=>candidate.type==="email"),value=field?values[field.name]:void 0;return typeof value==="string"&&value?value.toLowerCase():null}function pickName(form,fields,values){const named=form.settings.nameField,field=named?fields.find((candidate)=>candidate.name===named):fields.find((candidate)=>candidate.type==="text"&&/name/i.test(candidate.name)),value=field?values[field.name]:void 0;return typeof value==="string"&&value?value:null}export function computeAmountCents(form,fields,values){const payment=form.settings.payment;if(!payment)return null;switch(payment.mode){case"fixed":return payment.amountCents??0;case"field_sum":return fields.filter((field)=>field.type==="currency").reduce((sum,field)=>sum+(Number(values[field.name])||0),0);case"user_amount":{const field=payment.amountField?fields.find((candidate)=>candidate.name===payment.amountField):fields.find((candidate)=>candidate.type==="currency"),cents=field?Number(values[field.name])||0:0,floor=payment.minAmountCents??0;return Math.max(cents,floor)}default:return null}}export function validateSubmission(form,payload){const fields=visibleFields(form.fields,payload),errors={},values={};for(const field of fields){if(field.type==="section_break")continue;const raw=payload[field.name],problem=checkField(field,raw,form);if(problem){errors[field.name]=problem;continue}if(!isBlank(raw))values[field.name]=coerce(field,raw)}if(Object.keys(errors).length>0)return{ok:!1,errors};return{ok:!0,values,email:pickEmail(form,fields,values),name:pickName(form,fields,values),amountCents:computeAmountCents(form,fields,values)}}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/forms",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.74.
|
|
4
|
+
"version": "0.74.3",
|
|
5
5
|
"description": "The Stacks form-builder functionality.",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
"prepublishOnly": "bun run build"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@stacksjs/config": "0.74.
|
|
60
|
-
"@stacksjs/database": "0.74.
|
|
61
|
-
"@stacksjs/logging": "0.74.
|
|
62
|
-
"@stacksjs/notifications": "0.74.
|
|
59
|
+
"@stacksjs/config": "0.74.3",
|
|
60
|
+
"@stacksjs/database": "0.74.3",
|
|
61
|
+
"@stacksjs/logging": "0.74.3",
|
|
62
|
+
"@stacksjs/notifications": "0.74.3"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@stacksjs/storage": "0.74.
|
|
66
|
-
"@stacksjs/validation": "0.74.
|
|
65
|
+
"@stacksjs/storage": "0.74.3",
|
|
66
|
+
"@stacksjs/validation": "0.74.3",
|
|
67
67
|
"better-dx": "^0.2.24"
|
|
68
68
|
},
|
|
69
69
|
"sideEffects": false
|