144 lines
5.1 KiB
TypeScript
144 lines
5.1 KiB
TypeScript
import 'server-only'
|
|
|
|
import type { BasePayload } from 'payload'
|
|
|
|
import { verifyTurnstile } from '../turnstile/index.js'
|
|
import { checkRateLimit } from './rateLimit.js'
|
|
import { validateSubmission } from './validateSubmission.js'
|
|
|
|
export type SubmitFormArgs = {
|
|
/**
|
|
* Name of the GDPR consent checkbox. A field with this name must be checked
|
|
* for the submission to succeed (enforced server-side). Defaults to 'consent'.
|
|
*/
|
|
consentFieldName?: string
|
|
/** Submitted field data — shape matches the form's fields. */
|
|
data: Record<string, unknown>
|
|
/** Form-builder form ID this submission belongs to. */
|
|
formId: string
|
|
/** Client IP — used for Turnstile and rate limiting. */
|
|
ip?: string
|
|
/**
|
|
* Rate limit: max submissions per IP per minute. Defaults to 5.
|
|
* Set to 0 to disable (e.g. when a real limiter sits in front).
|
|
*/
|
|
maxPerMinute?: number
|
|
payload: BasePayload
|
|
/** Turnstile token; when present it is verified, when absent it is skipped. */
|
|
turnstileToken?: string
|
|
}
|
|
|
|
/**
|
|
* Why a submission failed — a code, never a user-facing string.
|
|
*
|
|
* The plugin knows what went wrong; it deliberately doesn't decide how to say
|
|
* it. The frontend maps these to its own copy, in its own language, and renders
|
|
* whatever component fits — a field error, a toast, a full message. The plugin
|
|
* has no business choosing the wording or the locale.
|
|
*
|
|
* - `rate_limited` — too many submissions from this IP
|
|
* - `turnstile` — bot check failed
|
|
* - `validation` — a field is missing/too long, or unknown keys were sent;
|
|
* `field` and `kind` narrow it down when a specific field is
|
|
* at fault (absent for whole-payload problems like unknown keys)
|
|
* - `not_found` — no form with this id
|
|
* - `consent` — a required GDPR consent checkbox was left unchecked
|
|
* - `error` — persistence failed unexpectedly
|
|
*/
|
|
export type SubmitFailure =
|
|
| {
|
|
/** The offending field's name, when one field is at fault. */
|
|
field?: string
|
|
/** What was wrong with it. */
|
|
kind?: 'required' | 'too_long' | 'unknown_fields'
|
|
reason: 'validation'
|
|
success: false
|
|
}
|
|
| {
|
|
field?: string
|
|
/** A GDPR consent field existed on the form but wasn't checked. */
|
|
reason: 'consent'
|
|
success: false
|
|
}
|
|
| { reason: 'error'; success: false }
|
|
| { reason: 'not_found'; success: false }
|
|
| { reason: 'rate_limited'; success: false }
|
|
| { reason: 'turnstile'; success: false }
|
|
|
|
export type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure
|
|
|
|
/**
|
|
* Handles a form submission end to end: rate limit, verify Turnstile, validate
|
|
* against the form's own schema, then store.
|
|
*
|
|
* The order is cost-ascending on purpose — the cheapest checks reject first, so
|
|
* a flood never reaches Turnstile's network call or the database.
|
|
*
|
|
* Emails aren't sent here. The form-builder sends whatever an editor configured
|
|
* under the form's "Emails" tab (form-submissions hook → payload.sendEmail),
|
|
* which goes out over panelSmtpAdapter. Storing the submission is enough.
|
|
*
|
|
* server-only: touches the Turnstile secret.
|
|
*/
|
|
export async function submitForm({
|
|
consentFieldName,
|
|
data,
|
|
formId,
|
|
ip,
|
|
maxPerMinute = 5,
|
|
payload,
|
|
turnstileToken,
|
|
}: SubmitFormArgs): Promise<SubmitFormResult> {
|
|
// 1. Rate limit — cheapest gate, drops a flood before any real work.
|
|
if (maxPerMinute > 0 && ip) {
|
|
if (!checkRateLimit({ key: ip, max: maxPerMinute })) {
|
|
return { reason: 'rate_limited', success: false }
|
|
}
|
|
}
|
|
|
|
// 2. Turnstile — verify when a token is supplied; reject on failure.
|
|
if (turnstileToken !== undefined) {
|
|
const ok = await verifyTurnstile({ ip, payload, token: turnstileToken })
|
|
if (!ok) {
|
|
return { reason: 'turnstile', success: false }
|
|
}
|
|
}
|
|
|
|
// 3. Validate against the form's schema. A public endpoint can't trust the
|
|
// shape of `data` — drop unknown keys, enforce required, cap length.
|
|
const validation = await validateSubmission(payload, formId, data, consentFieldName)
|
|
if (!validation.ok) {
|
|
if (validation.reason === 'not_found') {
|
|
return { reason: 'not_found', success: false }
|
|
}
|
|
if (validation.reason === 'consent') {
|
|
return { field: validation.field, reason: 'consent', success: false }
|
|
}
|
|
return {
|
|
reason: 'validation',
|
|
success: false,
|
|
...(validation.field ? { field: validation.field } : {}),
|
|
...(validation.kind ? { kind: validation.kind } : {}),
|
|
}
|
|
}
|
|
|
|
// 4. Persist (form-builder shape: submissionData array). Triggers the email
|
|
// hook. Only validated, known fields are stored.
|
|
try {
|
|
const submission = await payload.create({
|
|
collection: 'form-submissions',
|
|
data: {
|
|
form: formId,
|
|
submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({
|
|
field,
|
|
value: value == null ? '' : String(value),
|
|
})),
|
|
} as never,
|
|
})
|
|
return { submissionId: submission.id, success: true }
|
|
} catch (err) {
|
|
payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`)
|
|
return { reason: 'error', success: false }
|
|
}
|
|
}
|