value). * @param array $context Request context (post_id, page_url, turnstile_token?). * @return true|WP_Error True on success, WP_Error on failure. */ public static function process( $form_id, $post_id, $raw_data, $context = [] ) { $form_id = absint( $form_id ); if ( ! $form_id ) { return new WP_Error( 'invalid_form_id', 'Invalid form ID.' ); } $form = get_post( $form_id ); if ( ! $form || GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $form->post_type || 'publish' !== $form->post_status ) { return new WP_Error( 'form_not_found', 'Form not found.' ); } $meta = get_post_meta( $form_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, true ); $meta = is_array( $meta ) ? $meta : []; $form_settings = wp_parse_args( isset( $meta['config'] ) && is_array( $meta['config'] ) ? $meta['config'] : [], GenerateBlocks_Pro_Form_Post_Type::default_form_config() ); $actions = isset( $form_settings['actions'] ) && is_array( $form_settings['actions'] ) ? $form_settings['actions'] : []; if ( empty( $actions ) ) { $public_message = self::incomplete_configuration_public_message(); return new WP_Error( 'form_actions_empty', $public_message, [ 'public_message' => $public_message, ] ); } if ( ! class_exists( 'GenerateBlocks_Pro_Form_Schema_Builder' ) ) { return new WP_Error( 'form_schema_unavailable', 'Form schema unavailable.' ); } $field_schema_list = GenerateBlocks_Pro_Form_Schema_Builder::get_instance()->build_schema_from_content( $form->post_content ); if ( is_wp_error( $field_schema_list ) ) { return $field_schema_list; } if ( empty( $field_schema_list ) ) { return new WP_Error( 'form_schema_empty', 'Form has no fields.' ); } // Server-side Turnstile enforcement: require a token only when the // integration is fully configured (both secret and site key present). // A secret-only configuration can't render the widget, so the token // would never arrive and every submission would be rejected — the // editor UI explicitly promises fail-open until both keys are set. if ( ! empty( $form_settings['useTurnstile'] ) && '' !== GenerateBlocks_Pro_Form_Turnstile::get_secret_key() && '' !== GenerateBlocks_Pro_Form_Turnstile::get_site_key() && ! array_key_exists( 'turnstile_token', $context ) ) { return new WP_Error( 'turnstile_required', 'Turnstile verification required.' ); } // Convert the indexed schema list (order-preserving) into the // associative map the downstream convergence/sanitizer expect. $field_schema = []; foreach ( $field_schema_list as $entry ) { if ( ! is_array( $entry ) || empty( $entry['name'] ) ) { continue; } $field_schema[ $entry['name'] ] = self::schema_entry_to_map( $entry ); } // Evaluate conditional visibility server-side (see resolve_hidden_fields // for the convergence contract). Fields that remain hidden after // convergence are stripped from both the submitted data and the schema // so the sanitizer never sees them. $hidden_fields = self::resolve_hidden_fields( $field_schema, $raw_data ); foreach ( $hidden_fields as $name ) { unset( $raw_data[ $name ] ); unset( $field_schema[ $name ] ); } $sanitized = GenerateBlocks_Pro_Form_Sanitizer::sanitize_submission( $raw_data, $field_schema ); if ( is_wp_error( $sanitized ) ) { return $sanitized; } /** * Filter to validate sanitized form data before actions run. * * @since 2.6.0 * @param array $sanitized Sanitized field data. * @param int $form_id Form post ID. * @param array $form_settings Form config from meta. * @param array $context Request context. */ $sanitized = apply_filters( 'generateblocks_form_validate_submission', $sanitized, $form_id, $form_settings, $context ); if ( is_wp_error( $sanitized ) ) { return $sanitized; } $actions = self::resolve_active_actions( $actions, $form_settings, $field_schema, $sanitized ); $submission_id = false; if ( ! empty( $form_settings['storeSubmissions'] ) ) { $submission_id = GenerateBlocks_Pro_Form_Submissions::insert_received( $form_id, $post_id, $sanitized ); } $preflight_errors = self::validate_actions_before_execution( $actions, $form_settings, $field_schema_list, $sanitized ); if ( ! empty( $preflight_errors ) ) { return self::handle_action_failures( $form_id, $post_id, $sanitized, $preflight_errors, $submission_id ); } $errors = []; $has_blocking_action = self::has_blocking_actions( $actions ); foreach ( $actions as $action_name ) { if ( ! is_string( $action_name ) ) { continue; } $callback = GenerateBlocks_Pro_Form_Action_Registry::get( $action_name ); if ( ! $callback ) { if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) { continue; } // Unknown/deactivated action: treat as failure so a stored // submission is marked failed. Silent skip would hide data loss. $errors[] = 'Unknown action: ' . sanitize_key( $action_name ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'GenerateBlocks Form: unknown action "' . sanitize_key( $action_name ) . '" in form ' . $form_id ); continue; } $result = call_user_func( $callback, $sanitized, $form_settings, $context ); if ( is_wp_error( $result ) ) { if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) { continue; } $errors[] = $result; } } if ( ! empty( $errors ) ) { return self::handle_action_failures( $form_id, $post_id, $sanitized, $errors, $submission_id ); } if ( $submission_id ) { GenerateBlocks_Pro_Form_Submissions::update_status( $submission_id, 'processed', null, $form_id ); } if ( ! empty( $actions ) ) { GenerateBlocks_Pro_Form_Post_Type::record_delivery_success( $form_id ); } return true; } /** * Validate built-in action configuration before any action can run. * * This prevents partial delivery when an earlier action has a side effect * and a later action is guaranteed to fail from missing configuration. * * @param array $actions Action names from form config. * @param array $form_settings Form config from meta. * @param array $field_schema_list Derived field schema list. * @param array $sanitized_data Sanitized submitted data. * @return array Error messages or WP_Error instances. */ private static function validate_actions_before_execution( $actions, $form_settings, $field_schema_list, $sanitized_data ) { $errors = []; $has_blocking_action = self::has_blocking_actions( $actions ); foreach ( $actions as $action_name ) { if ( ! is_string( $action_name ) ) { continue; } $action_name = sanitize_key( $action_name ); $callback = GenerateBlocks_Pro_Form_Action_Registry::get( $action_name ); if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) { continue; } if ( ! $callback ) { $errors[] = 'Unknown action: ' . $action_name; continue; } $validation = self::validate_builtin_action_before_execution( $action_name, $form_settings, $field_schema_list, $sanitized_data ); if ( is_wp_error( $validation ) ) { $errors[] = $validation; } } return $errors; } /** * Filter configured actions down to actions active for this submission. * * The built-in email signup action can be gated by a single checkbox field * in contact + signup forms. Other actions always stay active. * * @param array $actions Configured action names. * @param array $form_settings Form config from meta. * @param array $field_schema Active field schema map. * @param array $sanitized_data Sanitized submitted data. * @return array Active action names. */ private static function resolve_active_actions( $actions, $form_settings, $field_schema, $sanitized_data ) { if ( ! is_array( $actions ) ) { return []; } $active = []; $action_keys = array_map( static function ( $action_name ) { return is_string( $action_name ) ? sanitize_key( $action_name ) : ''; }, $actions ); $is_contact_email_signup = in_array( 'email', $action_keys, true ) && in_array( 'email-signup', $action_keys, true ); foreach ( $actions as $action_name ) { if ( ! is_string( $action_name ) ) { continue; } if ( $is_contact_email_signup && 'email-signup' === sanitize_key( $action_name ) && ! self::email_signup_opt_in_allows_action( $form_settings, $field_schema, $sanitized_data ) ) { continue; } $active[] = $action_name; } return $active; } /** * Check whether an email signup opt-in field allows signup to run. * * Missing opt-in config means the action runs normally. When configured, it * must point at a visible checkbox field whose sanitized submitted value * matches that field's checked value. * * @param array $form_settings Form config from meta. * @param array $field_schema Active field schema map. * @param array $sanitized_data Sanitized submitted data. * @return bool Whether signup can run. */ private static function email_signup_opt_in_allows_action( $form_settings, $field_schema, $sanitized_data ) { $action_settings = $form_settings['actionSettings']['email-signup'] ?? null; if ( ! is_array( $action_settings ) ) { return true; } $raw_opt_in_field = $action_settings['optInField'] ?? ''; $opt_in_field = is_scalar( $raw_opt_in_field ) ? sanitize_key( (string) $raw_opt_in_field ) : ''; if ( '' === $opt_in_field ) { return true; } if ( 'checkbox' !== ( $field_schema[ $opt_in_field ]['type'] ?? '' ) ) { return false; } $field = $sanitized_data[ $opt_in_field ] ?? null; if ( ! is_array( $field ) ) { return false; } $actual = sanitize_text_field( (string) ( $field['value'] ?? '' ) ); $expected = sanitize_text_field( (string) ( $field_schema[ $opt_in_field ]['checkedValue'] ?? 'yes' ) ); return '' !== $actual && $actual === $expected; } /** * Validate configuration/data for built-in actions with known prerequisites. * * Third-party actions still run through the normal callback result handling. * * @param string $action_name Sanitized action name. * @param array $form_settings Form config from meta. * @param array $field_schema_list Derived field schema list. * @param array $sanitized_data Sanitized submitted data. * @return true|WP_Error */ private static function validate_builtin_action_before_execution( $action_name, $form_settings, $field_schema_list, $sanitized_data ) { switch ( $action_name ) { case 'email': if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Email' ) ) { return GenerateBlocks_Pro_Form_Action_Email::validate_settings( $form_settings ); } break; case 'webhook': if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Webhook' ) ) { return GenerateBlocks_Pro_Form_Action_Webhook::validate_settings( $form_settings ); } break; case 'confirmation-email': if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Confirmation_Email' ) ) { $settings_check = GenerateBlocks_Pro_Form_Action_Confirmation_Email::validate_settings( $form_settings, $field_schema_list ); if ( is_wp_error( $settings_check ) ) { return $settings_check; } return GenerateBlocks_Pro_Form_Action_Confirmation_Email::validate_submission( $sanitized_data, $form_settings ); } break; case 'email-signup': if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Email_Signup' ) ) { $settings = isset( $form_settings['actionSettings'][ GenerateBlocks_Pro_Form_Action_Email_Signup::ACTION ] ) ? $form_settings['actionSettings'][ GenerateBlocks_Pro_Form_Action_Email_Signup::ACTION ] : []; $settings_check = GenerateBlocks_Pro_Form_Action_Email_Signup::validate_settings( $settings, $field_schema_list ); if ( is_wp_error( $settings_check ) ) { return self::with_public_failure_message( $settings_check, self::incomplete_configuration_public_message() ); } return GenerateBlocks_Pro_Form_Action_Email_Signup::validate_submission( $sanitized_data, $settings ); } break; } return true; } /** * Check whether an action is secondary and should not fail other delivery. * * @param string $action_name Action name. * @return bool */ private static function is_non_blocking_action( $action_name ) { return 'confirmation-email' === sanitize_key( (string) $action_name ); } /** * Check whether the action list includes at least one primary delivery action. * * @param array $actions Action names. * @return bool */ private static function has_blocking_actions( $actions ) { foreach ( $actions as $action_name ) { if ( is_string( $action_name ) && ! self::is_non_blocking_action( $action_name ) ) { return true; } } return false; } /** * Persist failed action delivery and return a public-safe error. * * @param int $form_id gblocks_form post ID. * @param int $post_id Host page ID. * @param array $sanitized_data Sanitized submitted data. * @param array $errors Action error messages or WP_Error instances. * @param int $submission_id Stored submission ID, if storage succeeded. * @return WP_Error */ private static function handle_action_failures( $form_id, $post_id, $sanitized_data, $errors, $submission_id = 0 ) { $error_messages = self::normalize_failure_messages( $errors ); unset( $post_id, $sanitized_data ); if ( $submission_id ) { GenerateBlocks_Pro_Form_Submissions::update_status( $submission_id, 'failed', $error_messages, $form_id ); } GenerateBlocks_Pro_Form_Post_Type::record_delivery_failure( $form_id, $errors ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'GenerateBlocks Form Error [' . $form_id . ']: ' . implode( '; ', $error_messages ) ); $public_message = self::get_public_failure_message( $errors ); if ( '' !== $public_message ) { return new WP_Error( 'action_failed', $public_message, [ 'public_message' => $public_message, ] ); } return new WP_Error( 'action_failed', 'One or more actions failed.' ); } /** * Public-safe message for forms that are published before setup is complete. * * @return string */ private static function incomplete_configuration_public_message() { return __( 'This form is not fully configured. Please contact the site administrator.', 'generateblocks-pro' ); } /** * Attach a public-safe message while preserving the admin-facing error. * * @param WP_Error $error Original error. * @param string $public_message Message safe to show to submitters. * @return WP_Error */ private static function with_public_failure_message( WP_Error $error, $public_message ) { $existing_data = $error->get_error_data(); $data = is_array( $existing_data ) ? $existing_data : []; if ( null !== $existing_data && ! is_array( $existing_data ) ) { $data['private_data'] = $existing_data; } $data['public_message'] = sanitize_text_field( (string) $public_message ); $error->add_data( $data, $error->get_error_code() ); return $error; } /** * Convert mixed action failures to admin-facing strings. * * @param array $errors Action error messages or WP_Error instances. * @return array */ private static function normalize_failure_messages( $errors ) { $messages = []; foreach ( $errors as $error ) { if ( is_wp_error( $error ) ) { $error = $error->get_error_message(); } if ( is_string( $error ) && '' !== $error ) { $messages[] = $error; } } return $messages; } /** * Read the first explicitly public-safe failure message. * * @param array $errors Action error messages or WP_Error instances. * @return string */ private static function get_public_failure_message( $errors ) { foreach ( $errors as $error ) { if ( ! is_wp_error( $error ) ) { continue; } $data = $error->get_error_data(); if ( ! is_array( $data ) || empty( $data['public_message'] ) || ! is_string( $data['public_message'] ) ) { continue; } return sanitize_text_field( $data['public_message'] ); } return ''; } /** * Resolve which fields remain hidden after conditional-visibility convergence. * * Contract: every field with non-empty `conditions` starts hidden. On each * pass we build an effective data map where currently-hidden fields * contribute empty values, then calculate the next visibility state for all * conditional fields from that snapshot. Applying changes after the full * pass matches the frontend's fixed-point model and avoids DOM/schema order * deciding which fields submit. * * Matching the frontend's start-hidden model here means cascading * conditions (A reveals B reveals C) resolve the same way regardless * of schema order, and a browser that disables JS submits exactly the * fields the server expects. * * Public so unit tests can exercise the convergence in isolation. * * @param array $field_schema Associative field_name => config map. * @param array $raw_data Raw user-submitted data. * @return array Names of fields that remain hidden after convergence. */ public static function resolve_hidden_fields( $field_schema, $raw_data ) { $visibility = []; foreach ( $field_schema as $name => $config ) { $visibility[ $name ] = empty( $config['conditions'] ); } $passes = 0; $max_passes = max( 10, count( $visibility ) + 1 ); do { $changed = false; $effective_data = $raw_data; foreach ( $visibility as $field_name => $is_visible ) { if ( ! $is_visible ) { $effective_data[ $field_name ] = ''; } } $next_visibility = $visibility; foreach ( $field_schema as $name => $config ) { if ( empty( $config['conditions'] ) ) { $next_visibility[ $name ] = true; continue; } $next_visibility[ $name ] = self::evaluate_conditions( $config['conditions'], $field_schema, $effective_data ); } foreach ( $next_visibility as $name => $is_visible ) { if ( ( $visibility[ $name ] ?? false ) !== $is_visible ) { $changed = true; break; } } $visibility = $next_visibility; $passes++; } while ( $changed && $passes < $max_passes ); // Hitting the cap with state still changing means a circular or // pathologically deep condition graph. Log so an admin debugging "field // never reveals" has a signal — conditional fields fail closed, which is // the safe default for submission validation. if ( $changed ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'GenerateBlocks Form: conditional visibility did not converge in ' . $max_passes . ' passes; conditional fields failed closed.' ); foreach ( $field_schema as $name => $config ) { if ( ! empty( $config['conditions'] ) ) { $visibility[ $name ] = false; } } } $hidden_fields = []; foreach ( $visibility as $name => $is_visible ) { if ( ! $is_visible ) { $hidden_fields[] = $name; } } return $hidden_fields; } /** * Convert a schema-list entry into the map shape the sanitizer expects. * * Schema list uses `options` as array-of-{value,label}; the sanitizer * wants `allowedValues` as a flat array of strings. * * @param array $entry Schema entry. * @return array */ private static function schema_entry_to_map( $entry ) { $map = [ 'type' => $entry['type'] ?? 'text', 'required' => ! empty( $entry['required'] ), 'label' => $entry['label'] ?? $entry['name'] ?? '', ]; if ( isset( $entry['options'] ) && is_array( $entry['options'] ) ) { $values = []; foreach ( $entry['options'] as $option ) { if ( isset( $option['value'] ) && '' !== $option['value'] ) { $values[] = (string) $option['value']; } } $map['allowedValues'] = $values; } if ( isset( $entry['checkedValue'] ) ) { $map['checkedValue'] = $entry['checkedValue']; } if ( isset( $entry['conditions'] ) && is_array( $entry['conditions'] ) ) { $map['conditions'] = $entry['conditions']; } if ( isset( $entry['matchesField'] ) && is_string( $entry['matchesField'] ) ) { $map['matchesField'] = $entry['matchesField']; } return $map; } /** * Evaluate conditions for a field against submitted data. * * All conditions must be met (AND logic). If any trigger field is missing * from the schema, the condition is treated as not met. * * @param array $conditions Conditions. * @param array $field_schema Full schema. * @param array $raw_data Raw data. * @return bool */ private static function evaluate_conditions( $conditions, $field_schema, $raw_data ) { foreach ( $conditions as $condition ) { $trigger_name = $condition['field'] ?? ''; $operator = $condition['operator'] ?? 'is'; $expected = $condition['value'] ?? ''; if ( ! $trigger_name || ! isset( $field_schema[ $trigger_name ] ) ) { return false; } $actual = $raw_data[ $trigger_name ] ?? ''; $is_arr = is_array( $actual ); if ( ! $is_arr ) { $actual = sanitize_text_field( (string) $actual ); } switch ( $operator ) { case 'is': if ( $is_arr ? ! in_array( $expected, $actual, true ) : $actual !== $expected ) { return false; } break; case 'isnot': if ( $is_arr ? in_array( $expected, $actual, true ) : $actual === $expected ) { return false; } break; case 'isempty': if ( $is_arr ? ! empty( $actual ) : '' !== trim( $actual ) ) { return false; } break; case 'isnotempty': if ( $is_arr ? empty( $actual ) : '' === trim( $actual ) ) { return false; } break; default: return false; } } return true; } }