@stacksjs/forms 0.75.165 → 0.75.166

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 CHANGED
@@ -1,4 +1,5 @@
1
1
  export type { CreatedForm, CreateFormFieldInput, CreateFormInput } from './create';
2
+ export type { SpamPolicy, SpamSignals } from './spam';
2
3
  export type { SubmissionListRow, SubmitOptions, SubmitResult } from './submissions';
3
4
  export type {
4
5
  FieldChoice,
@@ -18,6 +19,7 @@ export { evaluateConditions, visibleFields } from './conditions';
18
19
  export { createForm, loadFormByHandle, toCreateFields } from './create';
19
20
  export { loadFormByUuid, publicDefinition } from './definition';
20
21
  export { dispatchSubmissionNotifications } from './notifications';
22
+ export { checkSubmissionSpam } from './spam';
21
23
  export {
22
24
  completeSubmissionPayment,
23
25
  exportSubmissionsCsv,
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,submissionIdentity,submitForm}from"./submissions";export{checkUpload,extensionOf,fileFieldNamed,formUploadPrefix,isOwnedUploadPath,resolveUploadLimits}from"./uploads";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{checkSubmissionSpam}from"./spam";export{completeSubmissionPayment,exportSubmissionsCsv,fetchSubmissions,submissionIdentity,submitForm}from"./submissions";export{checkUpload,extensionOf,fileFieldNamed,formUploadPrefix,isOwnedUploadPath,resolveUploadLimits}from"./uploads";export{computeAmountCents,validateSubmission}from"./validate";
package/dist/spam.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Why a submission looks automated, or null when it does not:
3
+ * `honeypot` (answer as if it worked, and store nothing, so the bot learns
4
+ * nothing) or `too_fast` (ask them to try again).
5
+ */
6
+ export declare function checkSubmissionSpam(signals: SpamSignals, policy?: SpamPolicy): 'honeypot' | 'too_fast' | null;
7
+ /**
8
+ * The two spam signals a public form can check without a CAPTCHA.
9
+ *
10
+ * - The honeypot: a field people never see (hidden with CSS, `tabindex=-1`,
11
+ * `autocomplete=off`). Form-filling bots fill every input, so any value
12
+ * in it means a bot.
13
+ * - The time on the page: the client reports how many milliseconds passed
14
+ * between showing the form and sending it. Nobody reads a form and types
15
+ * their name and email in under a few seconds.
16
+ *
17
+ * Neither proves a human, and neither tracks anyone or loads a third-party
18
+ * script. They stop the common drive-by bots; a rate limit per address or
19
+ * IP is still the caller's job.
20
+ */
21
+ export declare interface SpamSignals {
22
+ honeypot?: unknown
23
+ renderedAtMs?: unknown
24
+ }
25
+ export declare interface SpamPolicy {
26
+ honeypot?: boolean
27
+ minSubmitSeconds?: number
28
+ }
package/dist/spam.js ADDED
@@ -0,0 +1 @@
1
+ export function checkSubmissionSpam(signals,policy={}){if((policy.honeypot??!0)&&typeof signals.honeypot==="string"&&signals.honeypot.trim()!=="")return"honeypot";const minSeconds=policy.minSubmitSeconds??3,elapsed=typeof signals.renderedAtMs==="number"?signals.renderedAtMs:typeof signals.renderedAtMs==="string"&&signals.renderedAtMs.trim()!==""?Number(signals.renderedAtMs):void 0;if(minSeconds>0&&elapsed!==void 0&&Number.isFinite(elapsed)&&elapsed<minSeconds*1000)return"too_fast";return null}
@@ -1,2 +1,2 @@
1
- import{randomUUID}from"node:crypto";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database/runtime";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??{};if((spam.honeypot??!0)&&typeof options.honeypot==="string"&&options.honeypot.trim()!=="")return{ok:!0,submissionId:0,submissionUuid:"",status:"complete",amountCents:null,confirmation:form.settings.confirmation?.type==="message"?form.settings.confirmation.value:null,redirect:null};const minSeconds=spam.minSubmitSeconds??3;if(minSeconds>0&&options.renderedAtMs!==void 0&&options.renderedAtMs<minSeconds*1000)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={}){return(await db.selectFrom("form_submissions").where("form_id","=",formId).where("status","!=","spam").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),rows=await fetchSubmissions(form.id,{limit:1e5}),lines=[[...fields.map((field)=>field.label),"Email","Name","Status","Amount","Submitted at"].map(csvEscape).join(",")];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(","));return lines.join(`
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={}){return(await db.selectFrom("form_submissions").where("form_id","=",formId).where("status","!=","spam").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),rows=await fetchSubmissions(form.id,{limit:1e5}),lines=[[...fields.map((field)=>field.label),"Email","Name","Status","Amount","Submitted at"].map(csvEscape).join(",")];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(","));return lines.join(`
2
2
  `)}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/forms",
3
3
  "type": "module",
4
- "version": "0.75.165",
4
+ "version": "0.75.166",
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.75.165",
60
- "@stacksjs/database": "0.75.165",
61
- "@stacksjs/logging": "0.75.165",
62
- "@stacksjs/notifications": "0.75.165"
59
+ "@stacksjs/config": "0.75.166",
60
+ "@stacksjs/database": "0.75.166",
61
+ "@stacksjs/logging": "0.75.166",
62
+ "@stacksjs/notifications": "0.75.166"
63
63
  },
64
64
  "devDependencies": {
65
- "@stacksjs/storage": "0.75.165",
66
- "@stacksjs/validation": "0.75.165",
65
+ "@stacksjs/storage": "0.75.166",
66
+ "@stacksjs/validation": "0.75.166",
67
67
  "better-dx": "^0.2.24"
68
68
  },
69
69
  "sideEffects": false