__( 'Security check failed. Please refresh and try again.', 'phemightforms' ) ], 403 ); } if ( ! empty( $_POST['phemightforms_hp'] ) ) { wp_send_json_error( [ 'message' => __( 'Spam detected.', 'phemightforms' ) ], 400 ); } if ( ! Captcha::verify() ) { wp_send_json_error( [ 'message' => __( 'Captcha verification failed. Please try again.', 'phemightforms' ) ], 400 ); } $form = new Form( $form_id ); if ( ! $form->exists() ) { wp_send_json_error( [ 'message' => __( 'Form not found.', 'phemightforms' ) ], 404 ); } $raw = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( Integrations::is_spam( $form, $raw ) ) { wp_send_json_error( [ 'message' => __( 'Your submission was flagged as spam.', 'phemightforms' ) ], 400 ); } $result = $this->process( $form ); if ( is_wp_error( $result ) ) { wp_send_json_error( [ 'message' => $result->get_error_message(), 'errors' => $result->get_error_data(), ], 422 ); } wp_send_json_success( $this->build_confirmation( $form, $result ) ); } /** * Validate and persist a submission. * * @param Form $form Target form. * @return array|\WP_Error Stored data on success, error with per-field messages on failure. */ private function process( Form $form ) { $fields = $form->get_fields(); $registry = phemightforms()->fields; $raw = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce verified in handle(); values sanitized per field below. // First pass: collect raw display values keyed by field id (for logic). $values_by_id = []; foreach ( $fields as $field ) { $type = isset( $field['type'] ) ? $field['type'] : 'text'; if ( $registry->is_display( $type ) ) { continue; } $fid = (int) $field['id']; $values_by_id[ $fid ] = $this->raw_value( $field, $raw ); } $stored = []; $errors = []; foreach ( $fields as $field ) { $type = isset( $field['type'] ) ? $field['type'] : 'text'; if ( $registry->is_display( $type ) ) { continue; } $fid = (int) $field['id']; // Skip fields hidden by conditional logic. if ( ! $this->is_visible( $field, $values_by_id ) ) { continue; } $label = isset( $field['label'] ) && '' !== $field['label'] ? $field['label'] : $registry->all()[ $type ]['label']; $required = ! empty( $field['required'] ); $key = 'field_' . $fid; // File uploads are handled separately. if ( 'file' === $type ) { $file_result = $this->handle_file( $key, $required, $label ); if ( is_wp_error( $file_result ) ) { $errors[ $key ] = $file_result->get_error_message(); continue; } if ( '' !== $file_result ) { $stored[] = [ 'id' => $fid, 'label' => $label, 'type' => $type, 'value' => $file_result, ]; } continue; } $value = $values_by_id[ $fid ]; $display = $this->to_display( $type, $value ); if ( 'consent' === $type ) { $display = $this->is_empty( $value ) ? '' : ( ! empty( $field['consent_text'] ) ? $field['consent_text'] : __( 'Consented', 'phemightforms' ) ); } if ( $required && $this->is_empty( $value ) ) { $errors[ $key ] = sprintf( /* translators: %s: field label. */ __( '%s is required.', 'phemightforms' ), $label ); continue; } if ( ! $this->is_empty( $value ) && ! $this->is_valid( $type, $value ) ) { $errors[ $key ] = sprintf( /* translators: %s: field label. */ __( 'Please enter a valid value for %s.', 'phemightforms' ), $label ); continue; } $stored[] = [ 'id' => $fid, 'label' => $label, 'type' => $type, 'value' => $display, ]; } if ( $errors ) { return new \WP_Error( 'phemightforms_validation', __( 'Please correct the highlighted fields.', 'phemightforms' ), $errors ); } // Quiz scoring. if ( $form->setting( 'is_quiz' ) ) { $score = 0; $total = 0; foreach ( $fields as $field ) { if ( empty( $field['correct'] ) ) { continue; } $total++; $given = isset( $values_by_id[ (int) $field['id'] ] ) ? $this->to_display( '', $values_by_id[ (int) $field['id'] ] ) : ''; if ( 0 === strcasecmp( trim( $given ), trim( $field['correct'] ) ) ) { $score++; } } $this->quiz_score = $score . ' / ' . $total; $stored[] = [ 'id' => 0, 'label' => __( 'Quiz Score', 'phemightforms' ), 'type' => 'quiz', 'value' => $this->quiz_score, ]; } // Payments: compute the total server-side and record a transaction. if ( $form->setting( 'payments_enabled' ) && Payments::form_has_payments( $form ) ) { $order = Payments::compute_total( $form, $raw ); if ( $order['total'] > 0 || $order['subtotal'] > 0 ) { $method = $form->setting( 'payment_method', 'offline' ); if ( ! isset( Payments::enabled_providers()[ $method ] ) ) { $method = 'offline'; } $stored[] = [ 'id' => 0, 'label' => __( 'Order Total', 'phemightforms' ), 'type' => 'payment_summary', 'value' => Payments::format( $order['total'] ) . ( $order['coupon'] ? ' (' . sprintf( /* translators: %s: coupon code. */ __( 'coupon %s', 'phemightforms' ), $order['coupon']['code'] ) . ')' : '' ), ]; $stored[] = [ 'id' => 0, 'label' => __( 'Payment Method', 'phemightforms' ), 'type' => 'payment_summary', 'value' => ucfirst( $method ), ]; // Grab the customer's email for gateways that require one. $customer_email = ''; foreach ( $stored as $row ) { if ( 'email' === $row['type'] && is_email( $row['value'] ) ) { $customer_email = $row['value']; break; } } $this->payment = [ 'method' => $method, 'order' => $order, 'email' => $customer_email, 'txn_id' => Payments::record_transaction( [ 'form_id' => $form->id, 'amount' => $order['total'], 'method' => $method, 'coupon' => $order['coupon'] ? $order['coupon']['code'] : '', 'status' => 'pending', ] ), ]; } } if ( Plugin::get_option( 'store_entries', 1 ) ) { $this->store_entry( $form->id, $stored ); } Analytics::track_submission( $form->id ); if ( $form->setting( 'notify_enabled', 1 ) ) { $this->notify( $form, $stored, $values_by_id ); } Integrations::dispatch( $form, $stored ); return $stored; } /** * Last computed quiz score string, or null. * * @var string|null */ private $quiz_score = null; /** * Pending payment details for the current submission, or null. * * @var array|null */ private $payment = null; /** * Build the success payload (message / redirect) for the response. * * @param Form $form Form. * @param array $stored Stored data. * @return array */ private function build_confirmation( Form $form, array $stored ) { // A pending payment overrides the normal confirmation: send the // visitor to checkout, or show payment instructions. if ( $this->payment && $this->payment['order']['total'] > 0 ) { $method = $this->payment['method']; $total = $this->payment['order']['total']; $settings = Payments::settings(); // Redirect-based gateways (cards, wallets, hosted crypto checkout). if ( ! in_array( $method, [ 'offline', 'cryptowallet' ], true ) ) { $url = Payments::checkout_url( $form, $method, $total, $this->payment['txn_id'], $this->payment['email'] ); if ( $url ) { return [ 'message' => __( 'Thanks! Redirecting you to secure checkout...', 'phemightforms' ), 'redirect' => $url, ]; } } // Direct crypto wallet transfer: show the wallet addresses. if ( 'cryptowallet' === $method ) { $addresses = trim( (string) $settings['cryptowallet_addresses'] ); $note = trim( (string) $settings['cryptowallet_note'] ); return [ 'message' => sprintf( /* translators: 1: order total, 2: instructions, 3: wallet addresses */ __( "Thanks! Your order total is %1\$s.\n%2\$s\n\n%3\$s", 'phemightforms' ), Payments::format( $total ), $note, $addresses ), ]; } $note = trim( (string) $settings['offline_instructions'] ); return [ 'message' => sprintf( /* translators: 1: order total, 2: payment instructions */ __( 'Thanks! Your order total is %1$s. %2$s', 'phemightforms' ), Payments::format( $total ), $note ), ]; } $rules = $form->setting( 'confirm_rules', [] ); if ( is_array( $rules ) ) { foreach ( $rules as $rule ) { if ( empty( $rule['field'] ) || ! isset( $rule['value'] ) || empty( $rule['message'] ) ) { continue; } foreach ( $stored as $row ) { if ( isset( $row['id'] ) && (int) $rule['field'] === (int) $row['id'] && (string) $row['value'] === (string) $rule['value'] ) { return [ 'message' => $rule['message'] ]; } } } } $type = $form->setting( 'confirmation_type', 'message' ); if ( 'redirect' === $type && $form->setting( 'confirmation_redirect' ) ) { return [ 'redirect' => $form->setting( 'confirmation_redirect' ) ]; } if ( 'page' === $type && $form->setting( 'confirmation_page' ) ) { $url = get_permalink( (int) $form->setting( 'confirmation_page' ) ); if ( $url ) { return [ 'redirect' => $url ]; } } $message = $form->setting( 'confirmation', __( 'Thanks! Your submission has been received.', 'phemightforms' ) ); if ( null !== $this->quiz_score ) { $message .= ' ' . sprintf( /* translators: %s: quiz score. */ __( 'Your score: %s', 'phemightforms' ), $this->quiz_score ); } return [ 'message' => $message ]; } /** * POST the submission to an external webhook URL as JSON. * @deprecated Use Integrations::dispatch(). * * @param Form $form Form. * @param array $stored Stored data. */ private function dispatch_webhook( Form $form, array $stored ) { Integrations::dispatch( $form, $stored ); } /** * Find a field ID by its label. * * @param Form $form Form. * @param string $label Label. * @return int */ private function field_id_by_label( Form $form, $label ) { foreach ( $form->get_fields() as $field ) { if ( isset( $field['label'] ) && $field['label'] === $label ) { return (int) $field['id']; } } return 0; } /** * Pull and sanitize the raw value(s) for a field. * * @param array $field Field config. * @param array $raw Unslashed $_POST. * @return string|array */ private function raw_value( array $field, array $raw ) { $registry = phemightforms()->fields; $type = isset( $field['type'] ) ? $field['type'] : 'text'; $key = 'field_' . (int) $field['id']; if ( $registry->is_composite( $type ) ) { $parts = []; foreach ( $registry->composite_parts( $type ) as $part ) { $parts[ $part ] = isset( $raw[ $key . '_' . $part ] ) ? sanitize_text_field( $raw[ $key . '_' . $part ] ) : ''; } return $parts; } $value = isset( $raw[ $key ] ) ? $raw[ $key ] : ''; if ( is_array( $value ) ) { return array_map( 'sanitize_text_field', $value ); } switch ( $type ) { case 'email': return sanitize_email( $value ); case 'textarea': return sanitize_textarea_field( $value ); case 'url': return esc_url_raw( $value ); case 'number': return preg_replace( '/[^0-9.\-]/', '', $value ); default: return sanitize_text_field( $value ); } } /** * Turn a raw value into a human-readable string for storage/email. * * @param string $type Field type. * @param string|array $value Raw value. * @return string */ private function to_display( $type, $value ) { if ( ! is_array( $value ) ) { return (string) $value; } // Composite (associative) or multi-choice (indexed) — join non-empty parts. $parts = array_filter( $value, static function ( $v ) { return '' !== trim( (string) $v ); } ); return implode( ', ', $parts ); } /** * Evaluate conditional logic to decide if a field is visible. * * @param array $field Field config. * @param array $values_by_id Raw values keyed by field id. * @return bool */ private function is_visible( array $field, array $values_by_id ) { if ( empty( $field['conditional']['enabled'] ) || empty( $field['conditional']['rules'] ) ) { return true; } $cond = $field['conditional']; $action = isset( $cond['action'] ) ? $cond['action'] : 'show'; $logic = isset( $cond['logic'] ) ? $cond['logic'] : 'and'; $results = []; foreach ( $cond['rules'] as $rule ) { $target = isset( $values_by_id[ (int) $rule['field'] ] ) ? $values_by_id[ (int) $rule['field'] ] : ''; $target = $this->to_display( '', $target ); $rvalue = isset( $rule['value'] ) ? $rule['value'] : ''; switch ( $rule['operator'] ) { case 'is': $results[] = ( $target === $rvalue ); break; case 'is_not': $results[] = ( $target !== $rvalue ); break; case 'contains': $results[] = ( '' !== $rvalue && false !== stripos( $target, $rvalue ) ); break; case 'empty': $results[] = ( '' === trim( $target ) ); break; case 'not_empty': $results[] = ( '' !== trim( $target ) ); break; default: $results[] = false; } } $matched = 'or' === $logic ? in_array( true, $results, true ) : ! in_array( false, $results, true ); // "show" => visible when matched; "hide" => visible when NOT matched. return 'hide' === $action ? ! $matched : $matched; } /** * Whether a value counts as empty. * * @param string|array $value Value. * @return bool */ private function is_empty( $value ) { if ( is_array( $value ) ) { return 0 === count( array_filter( $value, static function ( $v ) { return '' !== trim( (string) $v ); } ) ); } return '' === trim( (string) $value ); } /** * Validate a value against its type. * * @param string $type Field type. * @param string|array $value Value. * @return bool */ private function is_valid( $type, $value ) { switch ( $type ) { case 'email': return (bool) is_email( $value ); case 'url': return '' !== esc_url_raw( $value ); case 'number': return is_numeric( $value ); case 'rating': return is_numeric( $value ) && (int) $value >= 1 && (int) $value <= 5; default: return true; } } /** * Handle a single file upload field. * * @param string $key Input name. * @param bool $required Whether the field is required. * @param string $label Field label for error messages. * @return string|\WP_Error URL on success (empty string if optional and skipped), error otherwise. */ private function handle_file( $key, $required, $label ) { $has_upload = isset( $_FILES[ $key ] ) && ! empty( $_FILES[ $key ]['name'] ) && UPLOAD_ERR_NO_FILE !== $_FILES[ $key ]['error']; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! $has_upload ) { if ( $required ) { return new \WP_Error( 'required', sprintf( /* translators: %s: field label. */ __( '%s is required.', 'phemightforms' ), $label ) ); } return ''; } require_once ABSPATH . 'wp-admin/includes/file.php'; $file = $_FILES[ $key ]; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput $overrides = [ 'test_form' => false, 'mimes' => $this->allowed_mimes(), ]; $moved = wp_handle_upload( $file, $overrides ); if ( isset( $moved['error'] ) ) { return new \WP_Error( 'upload', $moved['error'] ); } return isset( $moved['url'] ) ? esc_url_raw( $moved['url'] ) : ''; } /** * Allowed upload mime types (safe subset). * * @return array */ private function allowed_mimes() { return [ 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'txt' => 'text/plain', 'csv' => 'text/csv', 'zip' => 'application/zip', ]; } /** * Insert an entry row. * * @param int $form_id Form ID. * @param array $data Field data. */ private function store_entry( $form_id, array $data ) { global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery Install::entries_table(), [ 'form_id' => $form_id, 'fields' => wp_json_encode( $data ), 'ip_address' => Privacy::store_ip( $this->client_ip() ), 'user_agent' => Privacy::store_ua( isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '' ), 'status' => 'unread', 'created_at' => current_time( 'mysql' ), ], [ '%d', '%s', '%s', '%s', '%s', '%s' ] ); } /** * Send the admin notification email. * * @param Form $form Form. * @param array $data Field data. */ private function notify( Form $form, array $data, array $values_by_id = [] ) { $to = $this->resolve_notify_recipients( $form, $values_by_id ); $subject = $form->setting( 'notify_subject', __( 'New form submission', 'phemightforms' ) ); $lines = []; foreach ( $data as $row ) { $lines[] = $row['label'] . ': ' . $row['value']; } Mail::send( $to, $subject, implode( "\n", $lines ) ); } /** * Resolve notification recipients including smart routing rules. * * @param Form $form Form. * @param array $values_by_id Values keyed by field ID. * @return string|array */ private function resolve_notify_recipients( Form $form, array $values_by_id ) { $default = $form->setting( 'notify_to', get_option( 'admin_email' ) ); $routes = $form->setting( 'notify_routes', [] ); if ( ! is_array( $routes ) || ! $routes ) { return $default; } foreach ( $routes as $route ) { if ( empty( $route['field'] ) || empty( $route['value'] ) || empty( $route['email'] ) ) { continue; } $fid = (int) $route['field']; $given = isset( $values_by_id[ $fid ] ) ? $this->to_display( '', $values_by_id[ $fid ] ) : ''; if ( $given === $route['value'] && is_email( $route['email'] ) ) { return $route['email']; } } return $default; } /** * Best-effort client IP address. * * @return string */ private function client_ip() { if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } return ''; } }