117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import type { BasePayload } from 'payload'
|
|
|
|
/** A form-builder field, trimmed to what validation needs. */
|
|
type FormField = {
|
|
blockType?: string
|
|
label?: string
|
|
name?: string
|
|
required?: boolean | null
|
|
}
|
|
|
|
type FormDoc = {
|
|
fields?: FormField[]
|
|
id: number | string
|
|
/** Per-form notification address, when the client added the field. */
|
|
notificationEmail?: string
|
|
title?: string
|
|
}
|
|
|
|
/**
|
|
* Validation outcome — codes, not user-facing strings. The frontend turns these
|
|
* into its own copy (see SubmitFailure in submitForm).
|
|
*/
|
|
export type FormValidationResult =
|
|
| {
|
|
/** Offending field, when a single field is at fault. */
|
|
field?: string
|
|
kind: 'required' | 'too_long' | 'unknown_fields'
|
|
ok: false
|
|
reason: 'invalid'
|
|
}
|
|
| { cleaned: Record<string, unknown>; form: FormDoc; ok: true }
|
|
| { ok: false; reason: 'not_found' }
|
|
|
|
/** Field block types that don't carry a submittable value. */
|
|
const NON_DATA_BLOCKS = new Set(['message'])
|
|
|
|
/**
|
|
* Keys injected by the captcha widget itself, not by the form definition.
|
|
* Cloudflare Turnstile adds a hidden <input name="cf-turnstile-response"> after
|
|
* a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the
|
|
* plugin drives Turnstile end-to-end, these are legitimate artifacts — they
|
|
* must not count as "unknown fields" and trip the anti-tampering check.
|
|
*/
|
|
const CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response'])
|
|
|
|
/** Hard ceiling on a single field's length, independent of the form config. */
|
|
const MAX_FIELD_LENGTH = 5000
|
|
|
|
/**
|
|
* Checks submitted data against the form's own definition, rather than trusting
|
|
* whatever arrived.
|
|
*
|
|
* The server action is a public endpoint: a caller can skip the rendered form
|
|
* and post arbitrary keys. Without this, unknown fields would be stored,
|
|
* required fields could be missing, and an oversized value could sail through.
|
|
* So we load the form, keep only keys that are real fields, reject when a
|
|
* required one is blank, and cap length.
|
|
*
|
|
* Returns the loaded form on success so the caller doesn't fetch it twice, and
|
|
* a code + offending field on failure so the frontend can point at it.
|
|
*/
|
|
export async function validateSubmission(
|
|
payload: BasePayload,
|
|
formId: string,
|
|
data: Record<string, unknown>,
|
|
): Promise<FormValidationResult> {
|
|
let form: FormDoc
|
|
try {
|
|
form = (await payload.findByID({
|
|
id: formId,
|
|
collection: 'forms',
|
|
depth: 0,
|
|
})) as FormDoc
|
|
} catch {
|
|
return { ok: false, reason: 'not_found' }
|
|
}
|
|
|
|
const fields = (form.fields ?? []).filter(
|
|
(f): f is { name: string } & FormField =>
|
|
typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),
|
|
)
|
|
const known = new Map(fields.map((f) => [f.name, f]))
|
|
|
|
const cleaned: Record<string, unknown> = {}
|
|
|
|
for (const field of fields) {
|
|
const value = data[field.name]
|
|
const isBlank =
|
|
value == null || (typeof value === 'string' && value.trim() === '') || value === false
|
|
|
|
if (field.required && isBlank) {
|
|
return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }
|
|
}
|
|
|
|
if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {
|
|
return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }
|
|
}
|
|
|
|
// Only carry through keys that belong to the form — unknown keys from a
|
|
// hand-crafted request are dropped, not stored.
|
|
if (value !== undefined) {
|
|
cleaned[field.name] = value
|
|
}
|
|
}
|
|
|
|
// Reject outright if the payload carried keys the form doesn't define — a
|
|
// sign the request wasn't produced by the rendered form. Captcha keys are
|
|
// exempt: the widget injects them into the rendered form, so they're expected,
|
|
// not tampering.
|
|
const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k))
|
|
if (unknownKeys.length > 0) {
|
|
return { kind: 'unknown_fields', ok: false, reason: 'invalid' }
|
|
}
|
|
|
|
return { cleaned, form, ok: true }
|
|
}
|