@stacksjs/forms 0.75.422 → 0.75.431
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/README.md +10 -0
- package/dist/conditions.d.ts +2 -2
- package/dist/conditions.js +1 -1
- package/dist/create.js +1 -1
- package/dist/definition.js +1 -1
- package/dist/field-name.d.ts +2 -0
- package/dist/field-name.js +1 -0
- package/dist/submissions.d.ts +1 -1
- package/dist/submissions.js +1 -1
- package/dist/uploads.d.ts +1 -0
- package/dist/uploads.js +1 -1
- package/dist/validate.js +1 -1
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -25,3 +25,13 @@ The package exposes typed helpers for:
|
|
|
25
25
|
|
|
26
26
|
Application-specific form models and actions remain under `app/`. Reusable
|
|
27
27
|
form behavior belongs in this package.
|
|
28
|
+
|
|
29
|
+
Form UUID lookups require an exact site identity. A null site selects only global forms; an unresolved host in multi-site mode cannot read, upload to, or submit any form.
|
|
30
|
+
|
|
31
|
+
`createForm` replaces the definition and its fields atomically, with retries for concurrent provisioning. SQLite acquires its writer lock before reading. PostgreSQL uses serializable isolation and requires the same isolation in an enclosing transaction. MySQL uses a connection-owned site lock and a locking current read; call it outside an enclosing MySQL transaction so the lock handoff completes with the row commit. These paths also serialize the global/null-site identity, which ordinary SQL composite unique indexes do not enforce.
|
|
32
|
+
|
|
33
|
+
Conditional visibility resolves controller dependencies before using their values. Hidden and unknown values cannot control other fields; cyclic rules and reserved field names reject submissions. The browser and server use the same visibility helper.
|
|
34
|
+
|
|
35
|
+
Currency inputs are nonnegative safe integer cents, or decimal digit strings. Payment sources must be currency fields, and configured amounts and computed totals must remain safe integers. Date fields require real `YYYY-MM-DD` calendar dates. Disjoint field/application upload allowlists reject all uploads.
|
|
36
|
+
|
|
37
|
+
CSV exports paginate by submission ID and include the full result set without a fixed row cap. New submissions arriving after the first page are excluded from that export.
|
package/dist/conditions.d.ts
CHANGED
|
@@ -8,5 +8,5 @@ import type { FieldConditions, FormFieldDefinition } from './types';
|
|
|
8
8
|
* a required visible field nor smuggle values through a hidden one.
|
|
9
9
|
*/
|
|
10
10
|
export declare function evaluateConditions(conditions: FieldConditions | null | undefined, values: Record<string, unknown>): boolean;
|
|
11
|
-
/**
|
|
12
|
-
export declare function visibleFields(fields:
|
|
11
|
+
/** Resolve controller visibility before using its value, independent of field order. */
|
|
12
|
+
export declare function visibleFields<T extends Pick<FormFieldDefinition, 'name' | 'conditions'> & { position?: number }>(fields: T[], values: Record<string, unknown>): T[];
|
package/dist/conditions.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function evaluateConditions(conditions,values){if(!conditions||conditions.rules.length===0)return!0;const results=conditions.rules.map((rule)=>{const actual=values[rule.field];switch(rule.op){case"eq":return String(actual??"")===String(rule.value??"");case"neq":return String(actual??"")!==String(rule.value??"");case"contains":return String(actual??"").includes(String(rule.value??""));case"gt":return Number(actual)>Number(rule.value);case"lt":return Number(actual)<Number(rule.value);case"empty":return actual===void 0||actual===null||String(actual)==="";case"not_empty":return actual!==void 0&&actual!==null&&String(actual)!=="";default:return!1}}),matched=conditions.logic==="all"?results.every(Boolean):results.some(Boolean);return conditions.action==="show"?matched:!matched}export function visibleFields(fields,values){
|
|
1
|
+
export function evaluateConditions(conditions,values){if(!conditions||conditions.rules.length===0)return!0;const results=conditions.rules.map((rule)=>{const actual=Object.hasOwn(values,rule.field)?values[rule.field]:void 0;switch(rule.op){case"eq":return String(actual??"")===String(rule.value??"");case"neq":return String(actual??"")!==String(rule.value??"");case"contains":return String(actual??"").includes(String(rule.value??""));case"gt":return Number(actual)>Number(rule.value);case"lt":return Number(actual)<Number(rule.value);case"empty":return actual===void 0||actual===null||String(actual)==="";case"not_empty":return actual!==void 0&&actual!==null&&String(actual)!=="";default:return!1}}),matched=conditions.logic==="all"?results.every(Boolean):results.some(Boolean);return conditions.action==="show"?matched:!matched}export function visibleFields(fields,values){const byName=new Map(fields.map((field)=>[field.name,field])),visibility=new Map,resolving=new Set,visible=(field)=>{if(visibility.has(field.name))return visibility.get(field.name);if(resolving.has(field.name))throw RangeError("Form conditions contain a cycle");resolving.add(field.name);const controllers=Object.create(null);for(const rule of field.conditions?.rules??[]){const controller=byName.get(rule.field);if(controller&&visible(controller)&&Object.hasOwn(values,rule.field))controllers[rule.field]=values[rule.field]}const result=evaluateConditions(field.conditions,controllers);resolving.delete(field.name);visibility.set(field.name,result);return result};return fields.filter(visible).sort((a,b)=>(a.position??0)-(b.position??0))}
|
package/dist/create.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db}from"@stacksjs/database/runtime";import{loadFormByUuid}from"./definition";export async function createForm(siteId,input){const
|
|
1
|
+
import{db,getDatabaseDialect,isInTransaction,lockRow,readCommittedTransactionOptions}from"@stacksjs/database/runtime";import{isReservedFieldName}from"./field-name";import{loadFormByUuid}from"./definition";export async function createForm(siteId,input){if(input.fields.some((field)=>isReservedFieldName(field.name)))throw RangeError("Reserved form field name");if(new Set(input.fields.map((field)=>field.name)).size!==input.fields.length)throw RangeError("Form field names must be unique");const dialect=getDatabaseDialect();if(isInTransaction()&&dialect==="mysql")throw Error("createForm must run outside an enclosing MySQL transaction");if(isInTransaction()&&dialect==="postgres"){const isolation=(await db.unsafe("SHOW transaction_isolation").execute())[0]?.transaction_isolation;if(!["readcommitted","serializable"].includes(String(isolation).toLowerCase().replace(/[ _-]/g,"")))throw Error("createForm requires read committed or serializable isolation in the outer transaction")}return db.transaction(async(tx)=>{const siteLock=`stacks_forms_site_${siteId??"global"}`;if(dialect==="postgres")await tx.unsafe("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",[siteLock]);if(dialect==="mysql"){const[lock]=await tx.unsafe("SELECT GET_LOCK(?, 10) AS acquired",[siteLock]);if(Number(lock?.acquired)!==1)throw Error("Could not acquire form provisioning lock")}try{const existing=await lockRow(tx,"forms",{site_id:siteId,handle:input.handle}),settings=JSON.stringify(input.settings??{});let formId,uuid,created=!1;if(existing){formId=Number(existing.id);uuid=String(existing.uuid);await tx.updateTable("forms").set({name:input.name,status:input.status??"active",settings}).where("id","=",formId).execute()}else{uuid=crypto.randomUUID();await tx.insertInto("forms").values({uuid,site_id:siteId,name:input.name,handle:input.handle,status:input.status??"active",settings}).execute();const[row]=await tx.selectFrom("forms").where("uuid","=",uuid).execute();if(!row)throw Error("Created form could not be read");formId=Number(row.id);created=!0}await tx.deleteFrom("form_fields").where("form_id","=",formId).execute();let position=0;for(const field of input.fields)await tx.insertInto("form_fields").values({form_id:formId,name:field.name,label:field.label,type:field.type,required:field.required??!1,position:position++,width:field.width??"full",options:JSON.stringify(field.options??{}),conditions:field.conditions?JSON.stringify(field.conditions):null}).execute();return{id:formId,uuid,handle:input.handle,created}}finally{if(dialect==="mysql")await tx.unsafe("SELECT RELEASE_LOCK(?)",[siteLock])}},{...readCommittedTransactionOptions(dialect),retries:5,sqlStates:["23505"]})}export async function loadFormByHandle(siteId,handle){const row=await db.selectFrom("forms").select(["uuid"]).where("handle","=",handle).where("site_id",siteId===null?"is":"=",siteId).executeTakeFirst();if(!row)return null;return await loadFormByUuid(String(row.uuid),siteId)}export function toCreateFields(fields){return fields.map((field)=>({name:field.name,label:field.label,type:field.type,required:field.required,width:field.width,options:field.options,conditions:field.conditions}))}
|
package/dist/definition.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db}from"@stacksjs/database/runtime";function parseJson(raw,fallback){if(raw&&typeof
|
|
1
|
+
import{db}from"@stacksjs/database/runtime";function parseJson(raw,fallback){let decoded=raw;if(typeof raw==="string")try{decoded=JSON.parse(raw)}catch{return fallback}return decoded!==null&&typeof decoded==="object"&&!Array.isArray(decoded)?Object.fromEntries(Object.entries(decoded).filter(([,value])=>value!==null)):fallback}function validField(field){const options=field.options;if(options.choices!==void 0&&(!Array.isArray(options.choices)||!options.choices.every((choice)=>choice!==null&&typeof choice==="object"&&typeof choice.label==="string"&&typeof choice.value==="string")))return!1;if(options.accept!==void 0&&(!Array.isArray(options.accept)||!options.accept.every((type)=>typeof type==="string")))return!1;const conditions=field.conditions;if(!conditions)return!0;return["show","hide"].includes(conditions.action)&&["all","any"].includes(conditions.logic)&&Array.isArray(conditions.rules)&&conditions.rules.every((rule)=>rule!==null&&typeof rule==="object"&&typeof rule.field==="string"&&["eq","neq","contains","gt","lt","empty","not_empty"].includes(rule.op)&&(rule.value===void 0||typeof rule.value==="string"||typeof rule.value==="number"))}function validSettings(settings){if(settings.submitLabel!==void 0&&typeof settings.submitLabel!=="string")return!1;if(settings.notifyEmails!==void 0&&(!Array.isArray(settings.notifyEmails)||!settings.notifyEmails.every((email)=>typeof email==="string")))return!1;if(settings.confirmation!==void 0&&(!settings.confirmation||typeof settings.confirmation!=="object"||!["message","redirect"].includes(settings.confirmation.type)||typeof settings.confirmation.value!=="string"))return!1;if(settings.payment!==void 0&&(!settings.payment||typeof settings.payment!=="object"||!["fixed","user_amount","field_sum"].includes(settings.payment.mode)))return!1;return!0}function rowToField(row){return{name:row.name,label:row.label,type:row.type,required:row.required===!0||row.required===1,position:Number(row.position??0),width:row.width==="half"?"half":"full",options:parseJson(row.options,{}),conditions:parseJson(row.conditions,null)}}export async function loadFormByUuid(uuid,siteId){const form=await db.selectFrom("forms").where("uuid","=",uuid).where("site_id",siteId===null?"is":"=",siteId).select(["id","uuid","site_id","name","handle","status","settings"]).executeTakeFirst();if(!form)return null;const resolvedFields=(await db.selectFrom("form_fields").where("form_id","=",form.id).select(["name","label","type","required","position","width","options","conditions"]).orderBy("position","asc").execute()).map(rowToField),settings=parseJson(form.settings,{});if(!validSettings(settings)||!resolvedFields.every(validField))return null;return{id:Number(form.id),uuid:form.uuid,siteId:form.site_id==null?null:Number(form.site_id),name:form.name,handle:form.handle,status:["draft","active","closed"].includes(form.status)?form.status:"draft",settings,fields:resolvedFields}}export function publicDefinition(form){return{uuid:form.uuid,name:form.name,status:form.status,submitLabel:form.settings.submitLabel??"Submit",confirmation:form.settings.confirmation?.type==="message"?form.settings.confirmation.value:null,payment:form.settings.payment?{mode:form.settings.payment.mode,amountCents:form.settings.payment.mode==="fixed"?form.settings.payment.amountCents??0:void 0,currency:form.settings.payment.currency??"USD",minAmountCents:form.settings.payment.minAmountCents}:null,fields:form.fields.sort((a,b)=>a.position-b.position).map((field)=>({name:field.name,label:field.label,type:field.type,required:field.required,width:field.width,options:{placeholder:field.options.placeholder,choices:field.options.choices,min:field.options.min,max:field.options.max,accept:field.options.accept},conditions:field.conditions}))}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isReservedFieldName(name){return name==="__proto__"||name==="constructor"||name==="prototype"}
|
package/dist/submissions.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare function submitForm(form: FormDefinition, payload: Record<string,
|
|
|
10
10
|
/** Flip a paid submission to complete. Called from the payment webhook. */
|
|
11
11
|
export declare function completeSubmissionPayment(submissionUuid: string, paymentIntentId: string): Promise<boolean>;
|
|
12
12
|
/** Admin list, newest first. `formId` scoping is the caller's job to have authorized. */
|
|
13
|
-
export declare function fetchSubmissions(formId: number, options?: { limit?: number, offset?: number }): Promise<SubmissionListRow[]>;
|
|
13
|
+
export declare function fetchSubmissions(formId: number, options?: { limit?: number, offset?: number, beforeId?: number }): Promise<SubmissionListRow[]>;
|
|
14
14
|
/**
|
|
15
15
|
* Who sent a submission and what they said, for the notification mail.
|
|
16
16
|
*
|
package/dist/submissions.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{randomUUID}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{checkSubmissionSpam}from"./spam";import{validateSubmission}from"./validate";export async function submitForm(form,payload,options={}){if(form.status!=="active")return{ok:!1,status:409,message:"This form is not accepting responses."};const spam=config.forms?.spam??{},spamSignal=checkSubmissionSpam(options,spam);if(spamSignal==="honeypot")return{ok:!0,submissionId:0,submissionUuid:"",status:"complete",amountCents:null,confirmation:form.settings.confirmation?.type==="message"?form.settings.confirmation.value:null,redirect:null};if(spamSignal==="too_fast")return{ok:!1,status:429,message:"That was submitted too quickly. Please try again."};const validated=validateSubmission(form,payload);if(!validated.ok)return{ok:!1,status:422,errors:validated.errors};const status=form.settings.payment&&(validated.amountCents??0)>0?"pending_payment":"complete",uuid=randomUUID(),now=sqlDateTime(new Date);await db.insertInto("form_submissions").values({uuid,form_id:form.id,site_id:form.siteId,data:JSON.stringify(validated.values),email:validated.email,name:validated.name,status,amount_cents:validated.amountCents,ip:options.ip??null,submitted_at:now,created_at:now,updated_at:now}).execute();const row=await db.selectFrom("form_submissions").where("uuid","=",uuid).select(["id"]).executeTakeFirst(),confirmation=form.settings.confirmation;return{ok:!0,submissionId:Number(row?.id??0),submissionUuid:uuid,status,amountCents:validated.amountCents,confirmation:confirmation?.type==="message"?confirmation.value:null,redirect:confirmation?.type==="redirect"?confirmation.value:null}}export async function completeSubmissionPayment(submissionUuid,paymentIntentId){const now=sqlDateTime(new Date),updated=await db.updateTable("form_submissions").set({status:"complete",payment_intent_id:paymentIntentId,updated_at:now}).where("uuid","=",submissionUuid).where("status","=","pending_payment").execute();return(typeof updated==="number"?updated:Number(updated?.changes??0))>0}export async function fetchSubmissions(formId,options={}){
|
|
1
|
+
import{randomUUID}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";import{checkSubmissionSpam}from"./spam";import{validateSubmission}from"./validate";export async function submitForm(form,payload,options={}){if(form.status!=="active")return{ok:!1,status:409,message:"This form is not accepting responses."};const spam=config.forms?.spam??{},spamSignal=checkSubmissionSpam(options,spam);if(spamSignal==="honeypot")return{ok:!0,submissionId:0,submissionUuid:"",status:"complete",amountCents:null,confirmation:form.settings.confirmation?.type==="message"?form.settings.confirmation.value:null,redirect:null};if(spamSignal==="too_fast")return{ok:!1,status:429,message:"That was submitted too quickly. Please try again."};const validated=validateSubmission(form,payload);if(!validated.ok)return{ok:!1,status:422,errors:validated.errors};const status=form.settings.payment&&(validated.amountCents??0)>0?"pending_payment":"complete",uuid=randomUUID(),now=sqlDateTime(new Date);await db.insertInto("form_submissions").values({uuid,form_id:form.id,site_id:form.siteId,data:JSON.stringify(validated.values),email:validated.email,name:validated.name,status,amount_cents:validated.amountCents,ip:options.ip??null,submitted_at:now,created_at:now,updated_at:now}).execute();const row=await db.selectFrom("form_submissions").where("uuid","=",uuid).select(["id"]).executeTakeFirst(),confirmation=form.settings.confirmation;return{ok:!0,submissionId:Number(row?.id??0),submissionUuid:uuid,status,amountCents:validated.amountCents,confirmation:confirmation?.type==="message"?confirmation.value:null,redirect:confirmation?.type==="redirect"?confirmation.value:null}}export async function completeSubmissionPayment(submissionUuid,paymentIntentId){const now=sqlDateTime(new Date),updated=await db.updateTable("form_submissions").set({status:"complete",payment_intent_id:paymentIntentId,updated_at:now}).where("uuid","=",submissionUuid).where("status","=","pending_payment").execute();return(typeof updated==="number"?updated:Number(updated?.changes??0))>0}export async function fetchSubmissions(formId,options={}){let query=db.selectFrom("form_submissions").where("form_id","=",formId).where("status","!=","spam");if(options.beforeId!==void 0)query=query.where("id","<",options.beforeId);return(await query.select(["id","uuid","data","email","name","status","amount_cents","submitted_at"]).orderBy("id","desc").limit(options.limit??100).offset(options.offset??0).execute()).map((row)=>({id:Number(row.id),uuid:row.uuid,values:(()=>{try{return row.data?JSON.parse(row.data):{}}catch{return{}}})(),email:row.email,name:row.name,status:row.status,amountCents:row.amount_cents==null?null:Number(row.amount_cents),submittedAt:row.submitted_at}))}export async function submissionIdentity(submissionId){const row=await db.selectFrom("form_submissions").where("id","=",submissionId).select(["email","name","data"]).executeTakeFirst();let values={};try{values=row?.data?JSON.parse(row.data):{}}catch{}return{email:row?.email??null,name:row?.name??null,values}}function csvEscape(value){const text=value===void 0||value===null?"":String(value),defused=/^[=+\-@]/.test(text)?`'${text}`:text;return/[",\n\r]/.test(defused)?`"${defused.replace(/"/g,'""')}"`:defused}export async function exportSubmissionsCsv(form){const fields=form.fields.filter((field)=>field.type!=="section_break").sort((a,b)=>a.position-b.position),lines=[[...fields.map((field)=>field.label),"Email","Name","Status","Amount","Submitted at"].map(csvEscape).join(",")];let beforeId;const pageSize=1000;while(!0){const rows=await fetchSubmissions(form.id,{limit:pageSize,beforeId});for(const row of rows)lines.push([...fields.map((field)=>csvEscape(row.values[field.name])),csvEscape(row.email),csvEscape(row.name),csvEscape(row.status),csvEscape(row.amountCents==null?"":(row.amountCents/100).toFixed(2)),csvEscape(row.submittedAt)].join(","));if(rows.length<pageSize)break;beforeId=rows.at(-1).id}return lines.join(`
|
|
2
2
|
`)}
|
package/dist/uploads.d.ts
CHANGED
package/dist/uploads.js
CHANGED
|
@@ -1 +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}
|
|
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?appTypes.length?fieldTypes.filter((type)=>appTypes.includes(type)):fieldTypes:appTypes;return{disk:appConfig.disk,maxBytes:Math.round(maxMb*1024*1024),allowedTypes,denyAll:Boolean(appTypes.length&&fieldTypes?.length&&!allowedTypes.length)}}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.`}}if(limits.denyAll)return{status:422,message:`${field.label} has no permitted upload types.`};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";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(
|
|
1
|
+
import{isCalendarDate}from"@stacksjs/datetime";import{isReservedFieldName}from"./field-name";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 currencyCents(value){if(typeof value!=="number"&&(typeof value!=="string"||!/^\d+$/.test(value)))return null;const cents=Number(value);return Number.isSafeInteger(cents)&¢s>=0?cents:null}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(!isCalendarDate(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=currencyCents(value);if(cents===null)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;const amount=(value)=>{const cents=currencyCents(value??0);if(cents===null)throw RangeError("Payment amount must be nonnegative safe integer cents");return cents};switch(payment.mode){case"fixed":return amount(payment.amountCents);case"field_sum":return fields.filter((field)=>field.type==="currency").reduce((sum,field)=>amount(sum+amount(values[field.name])),0);case"user_amount":{const field=payment.amountField?form.fields.find((candidate)=>candidate.name===payment.amountField):form.fields.find((candidate)=>candidate.type==="currency");if(!field||field.type!=="currency")throw RangeError("Payment source must be a currency field");const cents=fields.some((candidate)=>candidate.name===field.name)?amount(values[field.name]):0;return Math.max(cents,amount(payment.minAmountCents))}default:throw RangeError("Unknown payment mode")}}export function validateSubmission(form,payload){if(form.fields.some((field)=>isReservedFieldName(field.name)))return{ok:!1,errors:{_form:"This form has an invalid field name."}};let fields;try{fields=visibleFields(form.fields,payload)}catch(error){if(!(error instanceof RangeError))throw error;return{ok:!1,errors:{_form:"This form has invalid field conditions."}}}const errors=Object.create(null),values=Object.create(null);for(const field of fields){if(field.type==="section_break")continue;const raw=Object.hasOwn(payload,field.name)?payload[field.name]:void 0,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};let amountCents;try{amountCents=computeAmountCents(form,fields,values)}catch(error){if(!(error instanceof RangeError))throw error;return{ok:!1,errors:{_form:"This form has an invalid payment amount."}}}return{ok:!0,values,email:pickEmail(form,fields,values),name:pickName(form,fields,values),amountCents}}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/forms",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.75.
|
|
4
|
+
"version": "0.75.431",
|
|
5
5
|
"description": "The Stacks form-builder functionality.",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -56,14 +56,15 @@
|
|
|
56
56
|
"prepublishOnly": "bun run build"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@stacksjs/config": "0.75.
|
|
60
|
-
"@stacksjs/database": "0.75.
|
|
61
|
-
"@stacksjs/
|
|
62
|
-
"@stacksjs/
|
|
59
|
+
"@stacksjs/config": "0.75.431",
|
|
60
|
+
"@stacksjs/database": "0.75.431",
|
|
61
|
+
"@stacksjs/datetime": "0.75.431",
|
|
62
|
+
"@stacksjs/logging": "0.75.431",
|
|
63
|
+
"@stacksjs/notifications": "0.75.431"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
|
-
"@stacksjs/storage": "0.75.
|
|
66
|
-
"@stacksjs/validation": "0.75.
|
|
66
|
+
"@stacksjs/storage": "0.75.431",
|
|
67
|
+
"@stacksjs/validation": "0.75.431",
|
|
67
68
|
"better-dx": "^0.2.24"
|
|
68
69
|
},
|
|
69
70
|
"sideEffects": false
|