init commit for iPAL-kit plugin
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import type { Plugin } from 'payload'
|
||||
|
||||
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
|
||||
|
||||
import type { FormsOption } from './types.js'
|
||||
|
||||
/**
|
||||
* Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.
|
||||
*
|
||||
* Provides the form/form-submissions collections and field types. Email
|
||||
* delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel
|
||||
* sender (submitForm) does that, so the form-builder's built-in email (which
|
||||
* needs a Payload email adapter) is left unused.
|
||||
*/
|
||||
export function buildFormsPlugin(forms: FormsOption): Plugin {
|
||||
return formBuilderPlugin({
|
||||
fields: {
|
||||
checkbox: true,
|
||||
email: true,
|
||||
message: true,
|
||||
number: true,
|
||||
payment: false,
|
||||
select: true,
|
||||
text: true,
|
||||
textarea: true,
|
||||
...forms.fields,
|
||||
},
|
||||
...(forms.redirectRelationships ? { redirectRelationships: forms.redirectRelationships } : {}),
|
||||
// Client-supplied collection overrides (e.g. a per-form notification
|
||||
// address). The plugin provides the hook, not the opinion about which
|
||||
// extra fields a form should carry.
|
||||
...(forms.formOverrides ? { formOverrides: forms.formOverrides } : {}),
|
||||
...(forms.formSubmissionOverrides
|
||||
? { formSubmissionOverrides: forms.formSubmissionOverrides }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only
|
||||
// (Turnstile secret) — never import it from a client component.
|
||||
export { buildFormsPlugin } from './formsPluginConfig.js'
|
||||
export { checkRateLimit } from './rateLimit.js'
|
||||
export type { RateLimitArgs } from './rateLimit.js'
|
||||
export { submitForm } from './submitForm.js'
|
||||
export type { SubmitFormArgs, SubmitFormResult } from './submitForm.js'
|
||||
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js'
|
||||
export { validateSubmission } from './validateSubmission.js'
|
||||
export type { FormValidationResult } from './validateSubmission.js'
|
||||
@@ -0,0 +1,62 @@
|
||||
type Bucket = { count: number; resetAt: number }
|
||||
|
||||
/**
|
||||
* Per-IP sliding window, in memory.
|
||||
*
|
||||
* Deliberately simple: no Redis, no dependency. The trade-off is that the
|
||||
* counter lives in one process — with several instances behind a load balancer
|
||||
* each keeps its own, so the effective limit is per-instance, not global. For a
|
||||
* contact form that's fine (it raises the cost of flooding without pretending
|
||||
* to be airtight); a high-security form should put a real limiter in front.
|
||||
*
|
||||
* State is module-level, so it survives between requests but resets on redeploy
|
||||
* — acceptable for abuse throttling.
|
||||
*/
|
||||
const buckets = new Map<string, Bucket>()
|
||||
|
||||
/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */
|
||||
let lastSweep = Date.now()
|
||||
const SWEEP_INTERVAL = 60_000
|
||||
|
||||
function sweep(now: number) {
|
||||
if (now - lastSweep < SWEEP_INTERVAL) {return}
|
||||
lastSweep = now
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt <= now) {buckets.delete(key)}
|
||||
}
|
||||
}
|
||||
|
||||
export type RateLimitArgs = {
|
||||
/** Identifier to limit on — typically the client IP. */
|
||||
key: string
|
||||
/** Max submissions allowed per window. Defaults to 5. */
|
||||
max?: number
|
||||
/** Window length in ms. Defaults to 60_000 (one minute). */
|
||||
windowMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the request is within the limit, false when it should be
|
||||
* rejected. A missing key (no IP) is allowed through — better to accept a
|
||||
* submission than to block everyone behind a proxy that strips the header.
|
||||
*/
|
||||
export function checkRateLimit({ key, max = 5, windowMs = 60_000 }: RateLimitArgs): boolean {
|
||||
if (!key) {return true}
|
||||
|
||||
const now = Date.now()
|
||||
sweep(now)
|
||||
|
||||
const bucket = buckets.get(key)
|
||||
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs })
|
||||
return true
|
||||
}
|
||||
|
||||
if (bucket.count >= max) {
|
||||
return false
|
||||
}
|
||||
|
||||
bucket.count += 1
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 = {
|
||||
/** 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
|
||||
* - `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
|
||||
}
|
||||
| { 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({
|
||||
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)
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'not_found') {
|
||||
return { reason: 'not_found', 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CollectionConfig, Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Receives the collection's default fields and returns the final list — add,
|
||||
* remove, or reorder. Same shape the form-builder uses.
|
||||
*/
|
||||
export type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]
|
||||
|
||||
/**
|
||||
* Overrides for a forms-related collection: replace the fields and/or any
|
||||
* other collection setting (admin, access, hooks…).
|
||||
*/
|
||||
export type FormsCollectionOverrides = {
|
||||
fields?: FormsFieldsOverride
|
||||
} & Partial<Omit<CollectionConfig, 'fields'>>
|
||||
|
||||
/**
|
||||
* Forms configuration — mirrors the fields a client enables in the
|
||||
* form-builder plugin. Kept minimal; the plugin passes these through.
|
||||
*/
|
||||
export type FormsOption = {
|
||||
/** Field types available in the form builder. Sensible defaults applied. */
|
||||
fields?: {
|
||||
checkbox?: boolean
|
||||
email?: boolean
|
||||
message?: boolean
|
||||
number?: boolean
|
||||
payment?: boolean
|
||||
select?: boolean
|
||||
text?: boolean
|
||||
textarea?: boolean
|
||||
}
|
||||
/**
|
||||
* Override the forms collection. The plugin stays opinion-free about what a
|
||||
* form needs beyond its fields — a client that wants, say, a per-form
|
||||
* notification address adds it here:
|
||||
*
|
||||
* formOverrides: {
|
||||
* fields: ({ defaultFields }) => [
|
||||
* ...defaultFields,
|
||||
* { name: 'notificationEmail', type: 'email' },
|
||||
* ],
|
||||
* }
|
||||
*/
|
||||
formOverrides?: FormsCollectionOverrides
|
||||
/** Override the form-submissions collection (same shape). */
|
||||
formSubmissionOverrides?: FormsCollectionOverrides
|
||||
/** Collections a form can redirect to (e.g. ['pages']). */
|
||||
redirectRelationships?: string[]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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'])
|
||||
|
||||
/** 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.
|
||||
const unknownKeys = Object.keys(data).filter((k) => !known.has(k))
|
||||
if (unknownKeys.length > 0) {
|
||||
return { kind: 'unknown_fields', ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
return { cleaned, form, ok: true }
|
||||
}
|
||||
Reference in New Issue
Block a user