sanitizer = $sanitizer ?? new ValueSanitizer(); $this->uploader = $uploader ?? new UploadManager(); $this->engine = $engine ?? new ValidationEngine(); $this->logic = $logic ?? new LogicResolver(); } /** * Returns the rule engine. * * Exposed so a site can register its own checks against the same engine the * submission runs through. */ public function engine(): ValidationEngine { return $this->engine; } /** * Validates the submitted values against the field set. * * @param array $fields Fields of the form. * @param array $raw Unslashed values submitted for the form. */ public function validate( array $fields, array $raw ): ValidationResult { $result = $this->run( $fields, $raw, null ); /** * Fires after the built in validation ran. * * Add-ons add their own rules by calling `add()` or `add_error()` on the * result. A rule that applies to one field is better registered with * {@see ValidationEngine::add_callback()}, which runs before this. * * @since 1.0.0 * * @param ValidationResult $result Validation result. * @param array $fields Fields of the form. */ do_action( 'formpilot_validate_submission', $result, $fields ); return $result; } /** * Validates the fields of one step of a multi-step form. * * Conditional logic is still resolved against the whole field set, because a * rule on this step may watch a field the visitor answered on an earlier one. * Only the fields named in `$keys` are held to their rules, so a step cannot * be refused for an answer the visitor has not been asked for yet. * * Uploads are checked but not stored: the file is sent again with the final * submission, and a step the visitor may still abandon should not leave one * on disk. * * @param array $fields Fields of the form. * @param array $raw Unslashed values submitted so far. * @param array $keys Field keys belonging to the step. */ public function validate_step( array $fields, array $raw, array $keys ): ValidationResult { $result = $this->run( $fields, $raw, $keys ); /** * Fires after one step was validated. * * @since 1.0.0 * * @param ValidationResult $result Validation result. * @param array $fields Fields of the form. * @param array $keys Field keys belonging to the step. */ do_action( 'formpilot_validate_step', $result, $fields, $keys ); return $result; } /** * Sanitises the submitted values without validating them. * * Used when progress is saved: a half filled form is not expected to pass its * rules, but nothing untrusted may be stored either. * * @param array $fields Fields of the form. * @param array $raw Unslashed values submitted for the form. * * @return array Clean values keyed by field key. */ public function sanitize( array $fields, array $raw ): array { $values = array(); foreach ( $fields as $field ) { if ( ! $field instanceof Field || FieldTypes::STEP === $field->type() ) { continue; } $values[ $field->field_key() ] = $this->sanitizer->sanitize( $field, $this->submitted( $field, $raw ) ); } return $values; } /** * Refuses an array submitted for a field that takes a single value. * * @param array $fields Fields of the form. * @param array $raw Unslashed values submitted for the form. * @param array|null $keys Field keys being checked, or null for all. * @param ValidationResult $result Result collecting values and errors. */ private function refuse_multi_values( array $fields, array $raw, ?array $keys, ValidationResult $result ): void { foreach ( $fields as $field ) { if ( ! $field instanceof Field || FieldTypes::STEP === $field->type() ) { continue; } $key = $field->field_key(); if ( null !== $keys && ! in_array( $key, $keys, true ) ) { continue; } if ( ! is_array( $raw[ $key ] ?? null ) || $this->sanitizer->accepts_many( $field ) ) { continue; } $result->add( new ValidationError( $key, 'invalid_value', sprintf( /* translators: %s: Field label. */ __( '%s accepts a single answer.', 'devplugins-custom-form-builder' ), $field->label() ) ) ); } } /** * Returns the value a field should be read as having. * * For every type but one that is simply what the request carried. A hidden * field is the exception: the visitor never sees it, so a value in it is * either something the author put there or something somebody typed into the * developer tools, and no validation rule supports the type to tell them * apart. A price, a coupon code or a routing key sitting in one was fully * attacker-controlled and was stored, emailed, posted to every webhook and * handed to every integration verbatim. * * So a hidden field that declares a default is answered from its definition, * not from the request. One that declares no default is still taken from the * request, because that is the shape a field populated by script on the page — * a campaign parameter, a referrer — has to have. * * @param Field $field Field being read. * @param array $raw Unslashed values submitted for the form. * * @return mixed The value to sanitise. */ private function submitted( Field $field, array $raw ): mixed { $key = $field->field_key(); if ( FieldTypes::HIDDEN !== $field->type() || '' === $field->default_value() ) { return $raw[ $key ] ?? ''; } /** * Filters whether a hidden field's stored default overrides what was submitted. * * @since 1.0.0 * * @param bool $authoritative Whether the stored default wins. Default true. * @param Field $field Field being read. */ return (bool) apply_filters( 'formpilot_hidden_default_wins', true, $field ) ? $field->default_value() : ( $raw[ $key ] ?? '' ); } /** * Runs the validation over every field, or over one step of them. * * @param array $fields Fields of the form. * @param array $raw Unslashed values submitted for the form. * @param array|null $keys Field keys to hold to their rules, or null for all of them. */ private function run( array $fields, array $raw, ?array $keys ): ValidationResult { $result = new ValidationResult(); $pending = array(); $checked = array(); $partial = null !== $keys; $values = $this->sanitize( $fields, $raw ); /* * Refused, not merely emptied. `ValueSanitizer` answers '' for an array on a * single-value field, which stops the old silent truncation — but on an * *optional* field an empty value passes, so the submission still succeeded * with part of what was sent discarded, which is the outcome that behaviour * exists to avoid. Saying so is the whole point. */ $this->refuse_multi_values( $fields, $raw, $keys, $result ); /* * Resolved server side from the submitted values rather than trusted * from the request. A field the browser hid can be re-enabled with the * developer tools, and a required field must not stop being required * because the visitor said so. */ $states = $this->logic->resolve( $fields, $this->with_upload_presence( $fields, $values ) ); foreach ( $fields as $field ) { if ( ! $field instanceof Field ) { continue; } $key = $field->field_key(); $state = $states[ $key ] ?? null; // A page break is a marker in the field list, never an answer. if ( FieldTypes::STEP === $field->type() ) { unset( $values[ $key ] ); continue; } // A field the visitor could not reach has nothing to answer for. if ( $state instanceof FieldState && ! $state->is_active() ) { unset( $values[ $key ] ); continue; } // Steps beyond the one being checked are not answered yet. if ( $partial && ! in_array( $key, $keys, true ) ) { continue; } if ( $state instanceof FieldState && $state->is_required() !== $field->is_required() ) { $field = $field->with_required( $state->is_required() ); } if ( FieldTypes::FILE === $field->type() ) { $entry = $this->check_upload( $field, $result ); if ( null !== $entry ) { $pending[] = array( $field, $entry ); } unset( $values[ $key ] ); continue; } $checked[] = $field; } $this->engine->validate( $checked, $values, $result ); // Checked before anything is written, so an over-budget set costs no disk. $this->uploads_within_budget( $pending, $result ); // Files are only moved once every other field passed, so a rejected // submission never leaves an orphaned upload on disk. if ( ! $partial && $result->is_valid() ) { $this->store_uploads( $pending, $result ); } return $result; } /** * Returns the values conditional logic reads, with file fields answered by the upload. * * A file arrives in `$_FILES`, never among the posted values, so a file field * always read as empty here: a rule such as "show Note when Attachment is not * empty" held in the browser and failed on the server, and the visitor's note * was dropped while the submission reported success. Worse, the posted value * was what the rule did read, so sending `formpilot_fields[attachment]=x` with no * file flipped it. A file field now counts as answered exactly when a file was * sent, and reads as that file's name. * * @param array $fields Fields of the form. * @param array $values Sanitised values keyed by field key. * * @return array The values to resolve logic over. */ private function with_upload_presence( array $fields, array $values ): array { foreach ( $fields as $field ) { if ( ! $field instanceof Field || FieldTypes::FILE !== $field->type() ) { continue; } $entry = $this->uploader->extract( FieldView::NAME_PREFIX, $field->field_key() ); $values[ $field->field_key() ] = is_array( $entry ) ? sanitize_file_name( (string) ( $entry['name'] ?? '' ) ) : ''; } return $values; } /** * Checks the file submitted for a field without storing it. * * @param Field $field Field being checked. * @param ValidationResult $result Result collecting values and errors. * * @return array|null The accepted upload awaiting storage, or null. */ private function check_upload( Field $field, ValidationResult $result ): ?array { $key = $field->field_key(); $entry = $this->uploader->extract( FieldView::NAME_PREFIX, $key ); if ( null === $entry ) { 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 null; } $result->add_value( $key, '' ); return null; } $error = $this->uploader->validate( $field, $entry ); if ( $error instanceof WP_Error ) { $result->add( new ValidationError( $key, (string) $error->get_error_code(), $error->get_error_message() ) ); /** * Fires when an upload is refused. * * The visitor is told something short and vague; this carries the * actual reason, which is what somebody debugging a rejected file * needs and what the visitor must not be handed. * * @since 1.0.0 * * @param WP_Error $error Reason the file was refused. * @param string $key Field the file was submitted for. * @param string $stage Whether it failed being checked or being stored. */ do_action( 'formpilot_upload_failed', $error, $key, 'validate' ); return null; } return $entry; } /** * Moves the accepted uploads into place. * * @param array}> $pending Uploads awaiting storage. * @param ValidationResult $result Result collecting values and errors. */ private function store_uploads( array $pending, ValidationResult $result ): void { $stored_files = array(); foreach ( $pending as $upload ) { list( $field, $entry ) = $upload; $stored = $this->uploader->store( $field, $entry ); if ( $stored instanceof WP_Error ) { $result->add( new ValidationError( $field->field_key(), (string) $stored->get_error_code(), $stored->get_error_message() ) ); /** This action is documented in src/Submissions/Validator.php */ do_action( 'formpilot_upload_failed', $stored, $field->field_key(), 'store' ); /* * The set is all or nothing. Two file fields where the first stores * and the second fails used to refuse the submission and leave the * first file on disk with nothing pointing at it — the exact orphan * the deferred-storage design exists to prevent. Whatever was * written before the failure is unwound here. */ $this->discard_uploads( $stored_files ); return; } $stored_files[] = $stored; $result->add_value( $field->field_key(), $stored->to_array() ); } } /** * Removes the files a validated submission wrote, when the entry itself failed to save. * * Validation stores uploads once every field has passed; the entry is written * afterwards. When that write rolls back, the files were left on disk with * nothing pointing at them until the orphan sweep ran. * * @param array $fields Fields of the form. * @param array $values Accepted values, as returned by validate(). */ public function discard_stored_uploads( array $fields, array $values ): void { $files = array(); foreach ( $fields as $field ) { if ( $field instanceof Field && FieldTypes::FILE === $field->type() && is_array( $values[ $field->field_key() ] ?? null ) ) { $files[] = UploadedFile::from_array( $values[ $field->field_key() ] ); } } $this->discard_uploads( $files ); } /** * Removes files written for a submission that is not going to be stored. * * @param array $files Files already written. */ private function discard_uploads( array $files ): void { foreach ( $files as $file ) { $this->uploader->delete( $file ); } } /** * Rejects an upload set that exceeds the per-submission budget. * * The manager enforces a limit per file. Without a limit across the request, a * form carrying ten file fields accepts ten times that limit in one POST, and * the attempt costs nothing to repeat. * * @param array}> $pending Uploads awaiting storage. * @param ValidationResult $result Result collecting values and errors. * * @return bool True when the set is within budget. */ private function uploads_within_budget( array $pending, ValidationResult $result ): bool { if ( array() === $pending ) { return true; } /** * Filters how many files one submission may carry. * * @since 1.0.0 * * @param int $count Largest number of files. Default 10. */ $max_files = max( 1, (int) apply_filters( 'formpilot_max_upload_count', 10 ) ); /** * Filters the combined size of the files one submission may carry, in bytes. * * @since 1.0.0 * * @param int $bytes Largest combined size. Defaults to the server's own limit. */ $max_bytes = max( 1, (int) apply_filters( 'formpilot_max_upload_total', (int) wp_max_upload_size() ) ); $total = 0; foreach ( $pending as $upload ) { $total += (int) ( $upload[1]['size'] ?? 0 ); } if ( count( $pending ) <= $max_files && $total <= $max_bytes ) { return true; } $result->add( new ValidationError( (string) $pending[0][0]->field_key(), 'formpilot_uploads_too_large', __( 'Too much was attached to this submission. Please send fewer or smaller files.', 'devplugins-custom-form-builder' ) ) ); return false; } }