*/ private array $rules; /** * Registry for site specific checks. * * @var CallbackRule */ private CallbackRule $callbacks; /** * Constructor. * * @param array|null $rules Optional. Rules to run. Defaults to the built in set. * @param CallbackRule|null $callbacks Optional. Registry for site specific checks. */ public function __construct( ?array $rules = null, ?CallbackRule $callbacks = null ) { $this->callbacks = $callbacks ?? new CallbackRule(); $this->rules = $rules ?? $this->default_rules(); } /** * Registers a check for a field key or a field type. * * @param string $target Field key, or a field type such as 'email'. * @param callable $callback Check to run. */ public function add_callback( string $target, callable $callback ): void { $this->callbacks->add( $target, $callback ); } /** * Adds a rule to the end of the chain. * * @param ValidationRule $rule Rule to run. */ public function add_rule( ValidationRule $rule ): void { $this->rules[] = $rule; } /** * Validates a set of values against the fields they belong to. * * Values are read from the field definitions rather than from the payload, * so a key the form never rendered is ignored instead of validated. * * @param array $fields Fields of the form. * @param array $values Sanitised values keyed by field key. * @param ValidationResult|null $result Optional. Result to add to. A new one is made when omitted. */ public function validate( array $fields, array $values, ?ValidationResult $result = null ): ValidationResult { $result = $result ?? new ValidationResult(); foreach ( $fields as $field ) { if ( ! $field instanceof Field ) { continue; } $this->validate_field( $field, $values[ $field->field_key() ] ?? '', $result ); } return $result; } /** * Validates one field. * * @param Field $field Field being validated. * @param mixed $value Sanitised value. * @param ValidationResult $result Result collecting values and failures. */ public function validate_field( Field $field, mixed $value, ValidationResult $result ): void { $key = $field->field_key(); if ( $this->is_empty( $value ) ) { if ( $field->is_required() ) { $result->add( new ValidationError( $key, 'required', sprintf( /* translators: %s: Field label. */ __( '%s is required.', 'devplugins-custom-form-builder' ), $field->label() ) ) ); return; } // Nothing to check, and nothing wrong with that. $result->add_value( $key, $value ); return; } /* * A ceiling that no field configuration can switch off, checked before the * rules rather than by one of them. Nothing else bounded how much an * unauthenticated request could write: the column is `longtext`, the * sanitisers clean without truncating, and no `maxlength` was ever emitted, * so a single POST could carry megabytes into the entry, the notification * email, the webhook payload and every integration. */ if ( $this->exceeds_hard_limit( $value ) ) { $result->add( new ValidationError( $key, 'too_long', sprintf( /* translators: %s: Field label. */ __( '%s is too long.', 'devplugins-custom-form-builder' ), $field->label() ) ) ); return; } foreach ( $this->rules_for( $field ) as $rule ) { $error = $rule->validate( $field, $value ); if ( $error instanceof ValidationError ) { $result->add( $error->for_field( $key ) ); return; } } $result->add_value( $key, $value ); } /** * Returns the rules that apply to a field. * * @param Field $field Field being validated. * * @return array The applicable rules, in order. */ private function rules_for( Field $field ): array { $rules = array(); foreach ( $this->rules as $rule ) { if ( $rule instanceof ValidationRule && $rule->supports( $field ) ) { $rules[] = $rule; } } return $rules; } /** * Determines whether a value is larger than any field is allowed to be. * * Measured in bytes, because bytes are what the column stores and what the * request carried. An array is measured as the sum of its parts, so a * checkbox group cannot slip past by spreading the payload over its options. * * This is a backstop against abuse, not a field length setting — a field that * wants a tighter bound says so through `min_length`/`max_length`, which * {@see \FormPilot\Validation\Rules\LengthRule} enforces. * * @param mixed $value Sanitised value. * * @return bool True when the value must be refused outright. */ private function exceeds_hard_limit( mixed $value ): bool { /** * Filters the largest value, in bytes, that any field will accept. * * @since 1.0.0 * * @param int $bytes Maximum size of one submitted value. Default 65536. */ $limit = (int) apply_filters( 'formpilot_max_value_bytes', self::MAX_VALUE_BYTES ); if ( $limit <= 0 ) { return false; } return $this->measure( $value ) > $limit; } /** * Returns the size of a submitted value in bytes. * * @param mixed $value Sanitised value. */ private function measure( mixed $value ): int { if ( is_array( $value ) ) { $total = 0; foreach ( $value as $item ) { $total += $this->measure( $item ); } return $total; } return is_scalar( $value ) ? strlen( (string) $value ) : 0; } /** * Determines whether a value counts as not filled in. * * @param mixed $value Sanitised value. * * @return bool True when nothing was submitted. */ private function is_empty( mixed $value ): bool { if ( is_array( $value ) ) { return array() === $value; } if ( is_string( $value ) ) { return '' === trim( $value ); } return null === $value; } /** * Returns the rules shipped with the plugin. * * Callbacks run last so a site rule sees a value the built in rules already * accepted, and can rely on its shape. * * @return array The rules, in order. */ private function default_rules(): array { $rules = array( new EmailRule(), new UrlRule(), new PhoneRule(), new NumberRule(), new DateRule(), new ChoiceRule(), new LengthRule(), $this->callbacks, ); /** * Filters the validation rules the engine runs. * * Every entry must implement {@see ValidationRule}. Order is preserved, * and the first rule to refuse a value stops the rest. * * @since 1.0.0 * * @param array $rules Rules to run, in order. */ $rules = (array) apply_filters( 'formpilot_validation_rules', $rules ); return array_values( array_filter( $rules, static fn( $rule ): bool => $rule instanceof ValidationRule ) ); } }