init commit for iPAL-kit plugin

This commit is contained in:
2026-07-18 20:30:23 +02:00
parent 10638127a9
commit 5733a9c8bf
119 changed files with 6892 additions and 411 deletions
+37
View File
@@ -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 }
: {}),
})
}
+10
View File
@@ -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'
+62
View File
@@ -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
}
+127
View File
@@ -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 }
}
}
+50
View File
@@ -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[]
}
+105
View File
@@ -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 }
}