get_error_message() ) ); } /** * Server-side repeater enforcement (roadmap issue #12): a client-side * Add/Remove button enforcing min/maxRows is trivially bypassable by * posting directly, and "required" on a repeater's child field * previously did nothing once submitted — sanitize_repeater_value() * only ever sanitized, never validated. * * Row-scoped conditional logic: `$repeater_rules` is every logicRule * defined on one of this repeater's own child fields (collected once * in handle(), see its own comment on why flatten_submittable_fields() * can't see them). Evaluated fresh per row — a rule referencing * another child field by id resolves against THAT row's own value * (the row array is overlaid on top of the full submission's * $raw_values, so a same-repeater sibling reference wins over * whatever a top-level field of the same... there is no collision * risk in practice since every field id is independently random, but * the overlay direction is what makes "sibling reference resolves * locally, top-level reference resolves to root form state" true even * if it ever were ambiguous) — never once against the top-level * $logic_result computed for the rest of the form, which has no * concept of "this row" at all. A child hidden by that row's own * logic is skipped entirely below (both the required check and format * validation) — a visitor was never shown it in this row, so nothing * they didn't fill in here should ever block the submission. */ /** * the reference product parity: every validation rule (including Required) can be * set to a per-field Custom message instead of the site-wide Global one * (Global Settings > Validation Messages). `requiredMessageMode` is only * ever 'custom' when the Builder's own Global/Custom switch was flipped * and a non-empty message typed — anything else (including a stray * 'custom' with a blank message, e.g. the switch was flipped then the * text cleared) falls back to the real global default, matching both * the Builder's read of the same value via ChargeFormsConfig and * Shortcode::required_attr()'s identical fallback logic. */ private function required_message( array $field ) { if ( 'custom' === ( $field['requiredMessageMode'] ?? '' ) && '' !== trim( (string) ( $field['requiredMessage'] ?? '' ) ) ) { return $field['requiredMessage']; } return Settings::get( 'validation_msg_required', __( 'This field is required', 'chargeforms' ) ); } private function validate_repeater_rows( array $field, array $rows, array $raw_values, array $repeater_rules, array $logic_extra ) { $min_rows = max( 1, (int) ( $field['minRows'] ?? 1 ) ); $max_rows = max( $min_rows, (int) ( $field['maxRows'] ?? 10 ) ); if ( count( $rows ) < $min_rows || count( $rows ) > $max_rows ) { wp_die( esc_html( sprintf( /* translators: 1: field label, 2: minimum rows, 3: maximum rows */ __( '"%1$s" must have between %2$d and %3$d entries.', 'chargeforms' ), $field['label'] ?? '', $min_rows, $max_rows ) ), 400 ); } $children = $field['children'] ?? []; foreach ( $rows as $row ) { $row_values = is_array( $row ) ? $row : []; // Root form state first, this row's own values layered on top — // a reference to a same-row sibling's id resolves locally; a // reference to any other (non-repeater) field id falls through // to root state, since it's never present in $row_values. $row_context = array_merge( $raw_values, $row_values ); $row_visibility = Evaluator::evaluate_rules( $repeater_rules, $children, [ 'field_values' => $row_context, 'extra' => $logic_extra ] ); foreach ( $children as $child ) { if ( ! ( $row_visibility['field_state'][ $child['id'] ]['visible'] ?? true ) ) { continue; } $val = $row_values[ $child['id'] ] ?? ''; $is_empty = is_array( $val ) ? empty( array_filter( $val, fn( $v ) => '' !== trim( (string) $v ) ) ) : '' === trim( (string) $val ); if ( ! empty( $child['required'] ) && $is_empty ) { wp_die( esc_html( sprintf( /* translators: %s: child field label */ __( '"%s" is required in every entry.', 'chargeforms' ), $child['label'] ?? $child['id'] ) ), 400 ); } // Real gap found live: a repeater child's email/number/url // format, maxLength, and Field_Validator rules (minLength/ // regex/min/max) were never enforced at all — only // "required" was, above. A garbage value in a repeater // row's email field (e.g. for double opt-in) previously // sailed straight through to storage and a "send to // whatever was typed" notification/opt-in email. if ( ! is_array( $val ) ) { $this->validate_scalar_format( $child, $val ); } } } } /** * Shared email/number/url shape + maxLength + Field_Validator * (minLength/regex/min/max) checks — factored out of the main field * loop below so validate_repeater_rows() (a repeater child field) can * apply the exact same rules a top-level field gets, instead of * skipping format validation entirely. Deliberately does NOT include * validateUnique (a DB lookup keyed by a single top-level field id — * not a meaningful concept for one cell inside a repeater row) or the * "required" check (each caller already has its own array-aware empty * check, since what counts as "empty" differs for multi-value fields). */ /** * the reference product parity, same Global/Custom pattern as required_message() * above: any format-validation rule (Email, Numeric, Url — Required is * handled separately since it lives on `field['required']`, not a * `field.validation` sub-key) can show a per-field Custom message * instead of its site-wide Global one. `$rule` is the schema-key prefix * ('email' -> `emailMessageMode`/`emailMessage`); `$global_key`/ * `$default` are that rule's existing Settings::get() call, unchanged. */ private function format_message( array $field, $rule, $global_key, $default ) { if ( 'custom' === ( $field[ $rule . 'MessageMode' ] ?? '' ) && '' !== trim( (string) ( $field[ $rule . 'Message' ] ?? '' ) ) ) { return $field[ $rule . 'Message' ]; } return Settings::get( $global_key, $default ); } private function validate_scalar_format( array $field, $val ) { if ( '' === trim( (string) $val ) ) { return; } $type = $field['type'] ?? ''; if ( 'email' === $type && ! is_email( $val ) ) { wp_die( esc_html( $this->format_message( $field, 'email', 'validation_msg_email', __( 'This field must contain a valid email', 'chargeforms' ) ) ), 400 ); } if ( 'email' === $type && Settings::get( 'block_disposable_emails', false ) && Disposable_Email_Domains::is_disposable( $val ) ) { wp_die( esc_html__( 'Temporary/disposable email addresses are not accepted. Please use a permanent email address.', 'chargeforms' ), 400 ); } if ( 'number' === $type && ! is_numeric( $val ) ) { wp_die( esc_html( $this->format_message( $field, 'numeric', 'validation_msg_numeric', __( 'This field must contain numeric value', 'chargeforms' ) ) ), 400 ); } if ( 'url' === $type && ! filter_var( $val, FILTER_VALIDATE_URL ) ) { wp_die( esc_html( $this->format_message( $field, 'url', 'validation_msg_url', __( 'This field must contain a valid url', 'chargeforms' ) ) ), 400 ); } if ( ! empty( $field['maxLength'] ) && strlen( (string) $val ) > (int) $field['maxLength'] ) { wp_die( esc_html__( 'One of your answers is too long. Please shorten it and try again.', 'chargeforms' ), 400 ); } $validation_error = Field_Validator::validate_value( $field, $val ); if ( null !== $validation_error ) { wp_die( esc_html( $validation_error ), 400 ); } } public function handle() { $form_id = isset( $_POST['form_id'] ) ? (int) $_POST['form_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read only to build the nonce action name checked on the next line. if ( ! $form_id || ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'chargeforms_submit_' . $form_id ) ) { wp_die( esc_html__( 'Security check failed. Please reload the page and try again.', 'chargeforms' ), 400 ); } if ( $this->is_rate_limited( $form_id ) ) { wp_die( esc_html__( 'Too many submissions — please wait a minute and try again.', 'chargeforms' ), 429 ); } $form = Form::get( $form_id ); if ( ! $form ) { wp_die( esc_html__( 'Form not found.', 'chargeforms' ), 404 ); } if ( 'published' !== ( $form['status'] ?? '' ) && ! current_user_can( 'chargeforms_manage_forms' ) ) { wp_die( esc_html__( 'This form is not currently accepting submissions.', 'chargeforms' ), 403 ); } // Re-check restrictions server-side — entry count/login state/schedule // can all change between page load and this POST, and a direct POST // bypasses the shortcode's render-time check entirely. $blocked = Restrictions::check( $form ); if ( null !== $blocked ) { wp_die( esc_html( $blocked['message'] ), 403 ); } // Honeypot: if filled, silently pretend success (bots don't get feedback). // $form must be loaded first so a legit visitor caught by autofill // still lands on the form's own configured redirect, same as a real submission. if ( Settings::get( 'honeypot_enabled', true ) && ! empty( $_POST['chargeforms_hp'] ) ) { Form_Start::record_spam_blocked( $form_id ); $this->redirect_back( $form_id, $form ); } if ( ! $this->recaptcha_ok( $form ) ) { wp_die( esc_html__( 'reCAPTCHA verification failed. Please try again.', 'chargeforms' ), 400 ); } if ( ! $this->hcaptcha_ok( $form ) ) { wp_die( esc_html__( 'hCaptcha verification failed. Please try again.', 'chargeforms' ), 400 ); } if ( ! $this->turnstile_ok( $form ) ) { wp_die( esc_html__( 'Turnstile verification failed. Please try again.', 'chargeforms' ), 400 ); } $raw_values = isset( $_POST['chargeforms_data'] ) ? wp_unslash( $_POST['chargeforms_data'] ) : []; // phpcs:ignore $values = []; // If this submission is finishing a Save & Resume draft, resolve it // once, up front — used below to promote any staged file uploads // (handle_file_upload()) and, after a successful Entry::create(), // to convert/delete the draft (Draft_Service::convert_to_entry()). // A missing/invalid/expired token is not an error here — it just // means this submission isn't attached to a draft, same as any // ordinary fresh submission. $draft_token = isset( $_POST['chargeforms_draft_token'] ) ? sanitize_text_field( wp_unslash( $_POST['chargeforms_draft_token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle() already verified the submit nonce above. $draft = $draft_token ? Draft::get_by_token( $draft_token ) : null; // Server-side visibility runs once, up front, through the shared // conditional-logic engine (Includes\Logic\Evaluator) — the exact // same evaluator the Builder and the front-end use — rather than a // per-field is_field_visible() call re-deriving the same result N // times with its own separate logic. field.logicRule IS the // engine's Rule shape already (Rule_Migrator permanently converts // the legacy field.conditional shape at the data layer, not here — // see includes/logic/class-rule-migrator.php), so this is a plain // extraction, not a compile step. $submittable_fields = Field_Sanitizer::flatten_submittable_fields( $form['fields'] ); $logic_rules = Evaluator::extract_field_rules( $submittable_fields ); // Row-scoped conditional logic (a repeater child field's own // logicRule, evaluated once per row against that row's own // sibling values). flatten_submittable_fields() deliberately // treats a repeater as one leaf value (its own doc comment // explains why — one array-of-rows value, not N independent // fields), so a repeater's children never appear in // $submittable_fields and their rules are invisible to the plain // extract_field_rules() call above. Collected here, per repeater, // so both this rule set AND its Context_Builder::build_extra() // scoping (urlParam/postMeta/etc. a repeater-row rule references) // are complete — folded into $all_rules below rather than // silently missing whatever a row-scoped rule asks for. $repeater_fields = array_values( array_filter( $submittable_fields, fn( $f ) => 'repeat_container' === $f['type'] ) ); $repeater_rules_by_id = []; $all_rules = $logic_rules; foreach ( $repeater_fields as $repeater_field ) { $child_rules = Evaluator::extract_field_rules( $repeater_field['children'] ?? [] ); $repeater_rules_by_id[ $repeater_field['id'] ] = $child_rules; $all_rules = array_merge( $all_rules, $child_rules ); } // Best-effort: a form POSTs to admin-post.php, so the page it was // actually embedded on isn't otherwise known server-side at // submission time — url_to_postid() on the referer is the same // approach already used elsewhere in this codebase for anything // that needs "which page was this form on" after the fact. $referer_post_id = isset( $_SERVER['HTTP_REFERER'] ) ? url_to_postid( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) ) : 0; $logic_extra = Context_Builder::build_extra( $all_rules, $referer_post_id ); $logic_result = Evaluator::evaluate_rules( $logic_rules, $submittable_fields, [ 'field_values' => $raw_values, 'extra' => $logic_extra ] ); foreach ( $submittable_fields as $field ) { // Milestone 5C — Product/Price/Subtotal/Tax/Discount/Total/ // Formula are display-only commerce lines with no `name=` // input of their own (see class-shortcode.php's render_field_row() // cases for each) — nothing to collect, and "required" would // never be satisfiable for them if it were ever set. if ( in_array( $field['type'], Field_Sanitizer::NON_INPUT_TYPES, true ) ) { continue; } $key = $field['id']; // Server-side is the real authority on visibility — never trust // the client's DOM state for either validation or storage. if ( ! ( $logic_result['field_state'][ $field['id'] ]['visible'] ?? true ) ) { continue; } if ( in_array( $field['type'], [ 'file', 'image_upload', 'featured_image' ], true ) ) { $values[ $key ] = $this->handle_file_upload( $field, $draft ); continue; } if ( 'signature' === $field['type'] ) { $values[ $key ] = $this->handle_signature_upload( $field, $raw_values[ $key ] ?? '' ); continue; } // Milestone 5D — Repeater. Collected whole, not per-scalar-field, // since one repeater is one array-of-rows value keyed by its own // id (see flatten_submittable_fields()'s doc comment for why // it's a leaf here rather than recursed into). if ( 'repeat_container' === $field['type'] ) { $rows = is_array( $raw_values[ $key ] ?? null ) ? array_values( $raw_values[ $key ] ) : []; $this->validate_repeater_rows( $field, $rows, $raw_values, $repeater_rules_by_id[ $field['id'] ] ?? [], $logic_extra ); $values[ $key ] = Field_Sanitizer::sanitize_repeater_value( $rows ); continue; } $val = $raw_values[ $key ] ?? ''; // Address (street/city/...), social_links, and multi-value // fields (multiple_choice, multi_select, and image_choice when // its own 'multiple' setting is on) are the ONLY field types // whose real ever produces an array — see each // one's render_*() call in this file. A real, exploitable // bug found live: this used to branch on is_array($val) alone // (whatever the client actually posted), so posting // `chargeforms_data[][]=x` for an ordinary // text/email/number/url field skipped EVERY format check below // (email shape, disposable-domain block, numeric/url shape, // maxLength, validateUnique, Field_Validator's minLength/ // regex/min/max) — the value was still accepted and stored. // Rejecting outright here closes that off instead of silently // treating attacker-supplied array shape as if it were a // legitimate multi-value field. $expects_array = in_array( $field['type'], [ 'multi_select', 'multiple_choice', 'address', 'social_links', 'categories', 'checkable_grid', 'chained_select', 'ranking' ], true ) || ( 'image_choice' === $field['type'] && ! empty( $field['multiple'] ) ); if ( is_array( $val ) && ! $expects_array ) { wp_die( esc_html__( 'Invalid submission.', 'chargeforms' ), 400 ); } // "Required" has to mean "every entry in the array is empty" // for a legitimately array-valued field, not "the array cast // to the string 'Array' is non-empty" (which it always is). if ( is_array( $val ) ) { $leaf_values = []; array_walk_recursive( $val, static function ( $value ) use ( &$leaf_values ) { $leaf_values[] = $value; } ); $is_empty = empty( array_filter( $leaf_values, fn( $v ) => '' !== trim( (string) $v ) ) ); } else { $is_empty = '' === trim( (string) $val ); } if ( 'chained_select' === $field['type'] ) { $chains = is_array( $field['chains'] ?? null ) ? array_values( $field['chains'] ) : []; $parent = ''; foreach ( $chains as $index => $chain ) { $selected = sanitize_text_field( (string) ( $val[ $index ] ?? '' ) ); if ( '' === $selected ) { if ( ! empty( $field['required'] ) ) { wp_die( esc_html( $this->required_message( $field ) ), 400 ); } $parent = ''; continue; } $valid = false; foreach ( (array) ( $chain['options'] ?? [] ) as $option ) { if ( is_array( $option ) && (string) ( $option['value'] ?? '' ) === $selected && ( 0 === $index || (string) ( $option['parent'] ?? '' ) === $parent ) ) { $valid = true; break; } } if ( ! $valid ) { wp_die( esc_html__( 'Invalid chained selection.', 'chargeforms' ), 400 ); } $parent = $selected; } } if ( 'ranking' === $field['type'] ) { $allowed = array_values( array_filter( array_map( 'strval', (array) ( $field['options'] ?? [] ) ) ) ); $submitted = array_values( array_map( 'sanitize_text_field', is_array( $val ) ? $val : [] ) ); if ( count( $submitted ) !== count( array_unique( $submitted ) ) || array_diff( $submitted, $allowed ) || count( $submitted ) !== count( $allowed ) ) { wp_die( esc_html__( 'Invalid ranking selection.', 'chargeforms' ), 400 ); } } if ( ! empty( $field['required'] ) && $is_empty ) { wp_die( esc_html( $this->required_message( $field ) ), 400 ); } if ( ! is_array( $val ) ) { $this->validate_scalar_format( $field, $val ); } if ( ! empty( $field['validateUnique'] ) && ! is_array( $val ) && '' !== trim( (string) $val ) && Entry::value_exists_for_field( $form_id, $key, (string) $val ) ) { wp_die( esc_html( sprintf( /* translators: %s: field label */ __( 'This %s has already been submitted before.', 'chargeforms' ), $field['label'] ) ), 400 ); } $values[ $key ] = 'categories' === $field['type'] ? array_values( array_filter( array_map( 'absint', is_array( $val ) ? $val : [ $val ] ) ) ) : ( in_array( $field['type'], [ 'post_update', 'dynamic_field' ], true ) ? absint( $val ) : Field_Sanitizer::sanitize_value( $val ) ); } // Math Field is computed live client-side (frontend.js's // computeCommerce()) and submitted as its own value — a visitor can // trivially overwrite that hidden input's value in devtools before // submitting. Recomputed here, as a separate pass after every other // field's value is already collected (a math field can reference an // operand field that appears later in $submittable_fields, so this // can't safely run inline in the loop above), from the SAME // server-collected $values a real client-side tamper attempt can't // touch, overwriting whatever the client actually posted. foreach ( $submittable_fields as $field ) { if ( 'math_field' !== $field['type'] ) { continue; } $values[ $field['id'] ] = $this->compute_math_field( $field, $values ); } foreach ( $submittable_fields as $field ) { if ( 'formula' === $field['type'] && ! empty( $field['expression'] ) ) { $calculated = \ChargeForms\Includes\Formula_Calculator::evaluate( $field['expression'], $values ); if ( null !== $calculated ) { $values[ $field['id'] ] = $calculated; } } } foreach ( $submittable_fields as $field ) { if ( 'quiz_score' !== $field['type'] ) { continue; } $score = 0; foreach ( (array) ( $field['quizRules'] ?? [] ) as $rule ) { $answer = $values[ sanitize_key( $rule['field'] ?? '' ) ] ?? ''; $answers = is_array( $rule['answers'] ?? null ) ? $rule['answers'] : [ $rule['answer'] ?? '' ]; if ( in_array( is_array( $answer ) ? implode( ',', $answer ) : (string) $answer, array_map( 'strval', $answers ), true ) ) { $score += (float) ( $rule['points'] ?? 0 ); } } $values[ $field['id'] ] = round( $score, 2 ); } if ( ! empty( $form['settings']['deny_empty_submission'] ) && Restrictions::is_entirely_empty( $values ) ) { wp_die( esc_html__( 'Please fill in at least one field before submitting.', 'chargeforms' ), 400 ); } $keyword_block = Restrictions::check_content( $form, $values ); if ( null !== $keyword_block ) { wp_die( esc_html( $keyword_block ), 403 ); } $validation_block = Restrictions::check_validation( $form, $values ); if ( null !== $validation_block ) { wp_die( esc_html( $validation_block ), 400 ); } $payment_result = $this->verify_payment( $form, $values ); if ( false === $payment_result ) { wp_die( esc_html__( 'Payment could not be verified. If you were charged, please contact us — otherwise no charge was made and you can try again.', 'chargeforms' ), 402 ); } $cleantalk_verdict = $this->cleantalk_check( $form, $values ); if ( false === $cleantalk_verdict && 'fail' === Settings::get( 'cleantalk_spam_validation', 'mark_spam' ) ) { wp_die( esc_html__( 'Your submission looks like spam and was rejected.', 'chargeforms' ), 400 ); } /** * Fires after every value is validated but BEFORE the entry row exists. * * This is the seam for anything that must claim a scarce, contended * resource as part of a submission — the Bookings add-on reserving an * appointment slot is the motivating case. Such a claim cannot run on * chargeforms_after_submission: by then the entry is already saved, so * a request that loses the race would leave a stranded entry behind * with nothing backing it. * * A listener that cannot proceed should reject the submission the same * way the checks above do — wp_die() with a message and a 4xx status — * rather than returning quietly, since there is no return value here * and execution would otherwise continue on to create the entry. * * @param array $form The form being submitted. * @param array $values The validated, sanitized submitted values. */ do_action( 'chargeforms_before_entry_create', $form, $values ); $entry_id = Entry::create( $form_id, $values ); if ( isset( $_SERVER['HTTP_REFERER'] ) ) { Entry::add_meta( $entry_id, 'source_url', esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) ); } if ( is_user_logged_in() ) { Entry::add_meta( $entry_id, 'submitted_by_user_id', get_current_user_id() ); } Entry::log_activity( $entry_id, __( 'Submission received', 'chargeforms' ), is_user_logged_in() ? get_current_user_id() : 0 ); // This submission finished a Save & Resume draft — the draft (and // any remaining staged files no field above already promoted) is // now fully superseded by a real entry and is deleted, not just // marked converted; a completed draft has no further use and // deleting it immediately (rather than waiting for the cleanup // cron) means its resume token stops working the instant it's no // longer needed, not days later. if ( $draft ) { Draft_Service::convert_to_entry( $draft, $entry_id ); } if ( is_array( $payment_result ) ) { // Legacy keys, Stripe only — every existing reader of // stripe_payment_intent_id/stripe_amount (anti-replay check // above, Entry::payment_transactions()) keeps working unchanged // against old AND new Stripe entries alike. New gateway-agnostic // keys are written for BOTH gateways going forward, additive — // no backfill of old entries (see class-entry.php's // payment_transactions() for how the two are reconciled). if ( 'stripe' === $payment_result['gateway'] ) { Entry::add_meta( $entry_id, 'stripe_payment_intent_id', $payment_result['transaction_id'] ); Entry::add_meta( $entry_id, 'stripe_amount', $payment_result['amount'] ); } Entry::add_meta( $entry_id, 'payment_gateway', $payment_result['gateway'] ); Entry::add_meta( $entry_id, 'payment_transaction_id', $payment_result['transaction_id'] ); Entry::add_meta( $entry_id, 'payment_amount', $payment_result['amount'] ); // A recurring payment additionally gets its own // wp_chargeforms_subscriptions row — the entry meta above is // still written (so it shows up on the Payments admin page the // same way a one-time payment does, transaction_id = the // subscription id), but ongoing status (renewed/past_due/ // canceled/expired) only ever lives on this row, kept current // by Stripe_Webhook_Handler and Subscription_Reconciler — never // by anything at submission time again after this one insert. if ( ! empty( $payment_result['is_subscription'] ) && ! empty( $payment_result['subscription'] ) ) { // Captured (previously discarded) so send_payment_receipt() // below can embed a "Manage your subscription" link — // Subscription_Portal::manage_url() needs this row's id, not // the gateway's own subscription id. $payment_result['subscription_row_id'] = Subscription::create( array_merge( $payment_result['subscription'], [ 'form_id' => $form_id, 'entry_id' => $entry_id, 'gateway' => $payment_result['gateway'], 'amount' => $payment_result['amount'], 'currency' => Settings::get( 'stripe_currency', 'usd' ), ] ) ); } } if ( false === $cleantalk_verdict ) { Entry::set_status( $entry_id, 'spam' ); if ( 'mark_spam_skip' === Settings::get( 'cleantalk_spam_validation', 'mark_spam' ) ) { // Flagged and recorded, but deliberately not notified/integrated — // the site owner asked to skip further processing for spam. $this->redirect_back( $form_id, $form, $values ); } } $optin_email = $this->double_optin_email( $form, $values ); if ( $optin_email ) { // Held as pending — notification, integrations, and the "counts // toward limit-entries" restriction all wait until the submitter // actually confirms, same as a real double opt-in should behave. Entry::set_status( $entry_id, 'pending' ); $this->send_optin_email( $form, $entry_id, $optin_email ); $this->redirect_back( $form_id, $form, $values, true ); } $this->run_action_hooks( $form, $values, $entry_id ); $this->maybe_update_post( $form, $values, $entry_id ); $this->maybe_create_post( $form, $values, $entry_id ); $this->send_notification( $form, $values, $entry_id ); if ( is_array( $payment_result ) ) { self::send_payment_receipt( $form, $values, $entry_id, $payment_result ); } /** * Fires after a submission is saved and the owner notification is sent. * Integration_Manager listens here to dispatch webhooks/CRM/etc. */ do_action( 'chargeforms_after_submission', $entry_id, $values, $form ); // Compliance: "Delete entry data after form submission" (real gap // found live against the reference product's Compliance Settings screen) — the // submission is still fully processed above (notification sent, // post created, integrations dispatched via the action just fired), // this only wipes the stored field values afterward so the entry row // survives for counting/auditing but no personal data sits in the // database at rest. Runs last, on purpose: every reader of $values // above needs the real data; only the row Entry::create() wrote // gets overwritten. if ( ! empty( $form['settings']['delete_entry_data'] ) ) { Entry::update( $entry_id, [] ); } $this->redirect_back( $form_id, $form, $values ); } /** * Creates (or, on a future re-submission, could update) a WordPress post * from this entry's Post Title / Post Content / Post Excerpt fields, when * the form has Post Fields enabled in its settings. Held until here (not * the double-opt-in branch above) so an unconfirmed submission never * creates real site content — same reasoning as notifications/integrations * waiting for confirmation. */ private function maybe_create_post( array $form, array $values, $entry_id ) { $settings = $form['settings'] ?? []; if ( empty( $settings['create_post_enabled'] ) ) { return; } $title = ''; $content = ''; $excerpt = ''; $categories = []; $taxonomy = 'category'; $tags = ''; $featured_image_id = 0; foreach ( $form['fields'] as $field ) { $value = $values[ $field['id'] ] ?? ''; if ( 'post_title' === $field['type'] && '' === $title ) { $title = $value; } elseif ( 'post_content' === $field['type'] && '' === $content ) { $content = $value; } elseif ( 'post_excerpt' === $field['type'] && '' === $excerpt ) { $excerpt = $value; } elseif ( 'categories' === $field['type'] ) { $categories = array_merge( $categories, is_array( $value ) ? $value : [ $value ] ); $taxonomy = sanitize_key( $field['taxonomy'] ?? 'category' ); if ( ! taxonomy_exists( $taxonomy ) ) { $taxonomy = 'category'; } } elseif ( 'tags' === $field['type'] && '' === $tags ) { $tags = sanitize_text_field( (string) $value ); } elseif ( 'featured_image' === $field['type'] && ! $featured_image_id ) { $featured_image_id = absint( $value ); } } // Nothing to build a post from — the form has the toggle on but no // Post Title/Content field, or the submitter left them both empty. if ( '' === trim( (string) $title ) && '' === trim( (string) $content ) ) { return; } $post_type = in_array( $settings['create_post_type'] ?? 'post', [ 'post', 'page' ], true ) ? $settings['create_post_type'] : 'post'; $post_status = in_array( $settings['create_post_status'] ?? 'draft', [ 'publish', 'draft', 'pending' ], true ) ? $settings['create_post_status'] : 'draft'; $post_id = wp_insert_post( [ 'post_type' => $post_type, 'post_status' => $post_status, 'post_title' => '' !== trim( (string) $title ) ? $title : __( '(no title)', 'chargeforms' ), 'post_content' => $content, 'post_excerpt' => $excerpt, // Anonymous submissions attribute to whoever built the form, rather // than requiring a separate "default author" setting up front. 'post_author' => is_user_logged_in() ? get_current_user_id() : (int) ( $form['created_by'] ?? 1 ), ], true ); if ( ! is_wp_error( $post_id ) && $post_id ) { if ( ! empty( $categories ) && taxonomy_exists( $taxonomy ) ) { wp_set_post_terms( $post_id, array_values( array_filter( array_map( 'absint', $categories ) ) ), $taxonomy, false ); } if ( '' !== trim( $tags ) && 'post' === $post_type ) { wp_set_post_tags( $post_id, array_filter( array_map( 'sanitize_text_field', array_map( 'trim', explode( ',', $tags ) ) ) ), false ); } if ( $featured_image_id && wp_attachment_is_image( $featured_image_id ) ) { set_post_thumbnail( $post_id, $featured_image_id ); } Entry::add_meta( $entry_id, 'created_post_id', $post_id ); } } private function run_action_hooks( array $form, array $values, $entry_id ) { foreach ( $form['fields'] as $field ) { if ( 'action_hook' !== ( $field['type'] ?? '' ) ) { continue; } $hook = sanitize_key( $field['hookName'] ?? $field['id'] ?? '' ); if ( '' !== $hook ) { do_action( 'chargeforms_action_' . $hook, $values, $entry_id, $form ); } } } /** Updates an explicitly selected post, restricted to posts the submitter can edit. */ private function maybe_update_post( array $form, array $values, $entry_id ) { $post_id = 0; $title = $content = $excerpt = ''; foreach ( $form['fields'] as $field ) { $value = $values[ $field['id'] ] ?? ''; if ( 'post_update' === $field['type'] && ! $post_id ) { $post_id = absint( $value ); } if ( 'post_title' === $field['type'] && '' === $title ) { $title = sanitize_text_field( $value ); } if ( 'post_content' === $field['type'] && '' === $content ) { $content = wp_kses_post( $value ); } if ( 'post_excerpt' === $field['type'] && '' === $excerpt ) { $excerpt = sanitize_textarea_field( $value ); } } $post = $post_id ? get_post( $post_id ) : null; if ( ! $post || ( ! current_user_can( 'edit_post', $post_id ) && (int) $post->post_author !== get_current_user_id() ) ) { return; } $updated = wp_update_post( [ 'ID' => $post_id, 'post_title' => $title, 'post_content' => $content, 'post_excerpt' => $excerpt ], true ); if ( ! is_wp_error( $updated ) ) { Entry::add_meta( $entry_id, 'updated_post_id', $post_id ); } } /** Confirms a pending double-opt-in entry via the emailed link, then fires notifications/integrations. */ public function handle_confirm() { // A per-entry cryptographic token (verified via hash_equals() below) // stands in for a nonce here — a nonce is tied to the current user's // session, but this link is opened from an email, often by a visitor // with no session at all. $entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $entry = Entry::get( $entry_id ); if ( ! $entry || ! hash_equals( $this->optin_token( $entry_id ), $token ) ) { wp_die( esc_html__( 'This confirmation link is invalid or has expired.', 'chargeforms' ), 400 ); } if ( 'pending' !== $entry['status'] ) { wp_die( esc_html__( 'This submission has already been confirmed.', 'chargeforms' ), 200 ); } $form = Form::get( $entry['form_id'] ); if ( ! $form ) { wp_die( esc_html__( 'Form not found.', 'chargeforms' ), 404 ); } Entry::set_status( $entry_id, 'unread' ); $this->run_action_hooks( $form, $entry['values'], $entry_id ); $this->maybe_update_post( $form, $entry['values'], $entry_id ); $this->maybe_create_post( $form, $entry['values'], $entry_id ); $this->send_notification( $form, $entry['values'], $entry_id ); do_action( 'chargeforms_after_submission', $entry_id, $entry['values'], $form ); nocache_headers(); echo '

' . esc_html__( 'Thanks — your submission is confirmed.', 'chargeforms' ) . '

'; exit; } /** * Returns the address to send a confirmation to if double opt-in applies * to this form, or '' if it doesn't (feature off, or no email field to * confirm to — double opt-in with nothing to send to isn't meaningful). */ private function double_optin_email( array $form, array $values ) { $setting = $form['settings']['double_optin'] ?? 'default'; $enabled = 'default' === $setting ? Settings::get( 'double_optin_enabled' ) : 'on' === $setting; if ( ! $enabled ) { return ''; } foreach ( $form['fields'] as $field ) { if ( 'email' === $field['type'] && ! empty( $values[ $field['id'] ] ) && is_email( $values[ $field['id'] ] ) ) { return $values[ $field['id'] ]; } } return ''; } /** Deterministic, site-secret-based token — no separate storage needed, re-derivable at confirm time. */ private function optin_token( $entry_id ) { return wp_hash( 'chargeforms_optin_' . $entry_id ); } private function send_optin_email( array $form, $entry_id, $email ) { $confirm_url = add_query_arg( [ 'action' => 'chargeforms_confirm_entry', 'entry_id' => $entry_id, 'token' => $this->optin_token( $entry_id ), ], admin_url( 'admin-post.php' ) ); $body = sprintf( /* translators: 1: form title, 2: confirmation link */ __( "Please confirm your submission to \"%1\$s\" by clicking the link below:\n\n%2\$s", 'chargeforms' ), $form['title'], $confirm_url ); wp_mail( $email, __( 'Please confirm your submission', 'chargeforms' ), $body ); } /** * Validates and moves an uploaded file via WordPress's own wp_handle_upload * (the same mechanism core media uploads use — it re-validates the MIME * type against the real file contents, not just the client-reported * extension, and never trusts the original filename verbatim). * Returns the resulting URL, or dies with a 400 on validation failure. */ /** * `$draft` (optional) is the resumed Save & Resume draft this * submission is finishing, if any — when the visitor already staged * this exact field's file during draft editing (Draft_File_Stager, * via the immediate-upload endpoint) and didn't pick a NEW file at * final submit, the already-uploaded staged file is promoted * (Draft::detach_file()) and reused as-is rather than requiring the * file to be re-selected/re-uploaded a second time. A fresh $_FILES * entry always wins over a staged one if the visitor changed their * mind at final submit. */ private function handle_file_upload( array $field, ?array $draft = null ) { $key = $field['id']; // This helper only runs from handle_submit()'s own field loop, which // already verified the chargeforms_submit_{form_id} nonce before // reaching any field — the sniff can't see that across the call. if ( empty( $_FILES['chargeforms_data']['name'][ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $draft ) { $staged = Draft::get_file_for_field( $draft['id'], $key ); if ( $staged ) { Draft::detach_file( $draft['id'], $key ); return esc_url_raw( $staged['file_url'] ); } } if ( ! empty( $field['required'] ) ) { wp_die( esc_html( $this->required_message( $field ) ), 400 ); } return ''; } // The client-sent 'name'/'type' are spoofable; File_Upload_Validator // checks the real extension, and wp_handle_upload() below re-derives // the real MIME type from the file itself (wp_check_filetype_and_ext()) // rather than trusting either of these — sanitized here only so // nothing unescaped/unslashed reaches that validation. $file = [ 'name' => sanitize_file_name( wp_unslash( $_FILES['chargeforms_data']['name'][ $key ] ?? '' ) ), // phpcs:ignore WordPress.Security.NonceVerification.Missing 'type' => sanitize_mime_type( wp_unslash( $_FILES['chargeforms_data']['type'][ $key ] ?? '' ) ), // phpcs:ignore WordPress.Security.NonceVerification.Missing 'tmp_name' => $_FILES['chargeforms_data']['tmp_name'][ $key ] ?? '', // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- PHP's own upload-tmp-dir path, not attacker content. 'error' => (int) ( $_FILES['chargeforms_data']['error'][ $key ] ?? UPLOAD_ERR_NO_FILE ), // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 'size' => (int) ( $_FILES['chargeforms_data']['size'][ $key ] ?? 0 ), // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized ]; $validation_error = File_Upload_Validator::validate( $field, $file ); if ( null !== $validation_error ) { wp_die( esc_html( $validation_error ), 400 ); } require_once ABSPATH . 'wp-admin/includes/file.php'; Protected_Uploads::ensure_protected_root(); add_filter( 'upload_dir', [ Protected_Uploads::class, 'filter_upload_dir' ] ); $overrides = [ 'test_form' => false ]; $moved = wp_handle_upload( $file, $overrides ); remove_filter( 'upload_dir', [ Protected_Uploads::class, 'filter_upload_dir' ] ); if ( isset( $moved['error'] ) ) { wp_die( esc_html__( 'There was a problem uploading your file. Please try again.', 'chargeforms' ), 400 ); } // Proxied through RestApi::protected_file() (chargeforms_manage_ // forms-gated) instead of the real, now web-server-denied URL — // files a visitor uploads through a form (resumes, IDs, any // attachment) are no longer publicly downloadable by anyone who // finds/guesses the link. See Protected_Uploads's doc comment. if ( 'featured_image' === ( $field['type'] ?? '' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; $attachment_id = wp_insert_attachment( [ 'post_mime_type' => sanitize_mime_type( $moved['type'] ?? '' ), 'post_title' => sanitize_text_field( pathinfo( $file['name'], PATHINFO_FILENAME ) ), 'post_status' => 'inherit', ], $moved['file'], 0, true ); if ( ! is_wp_error( $attachment_id ) && $attachment_id ) { $metadata = wp_generate_attachment_metadata( $attachment_id, $moved['file'] ); if ( $metadata ) { wp_update_attachment_metadata( $attachment_id, $metadata ); } return (string) $attachment_id; } wp_die( esc_html__( 'There was a problem preparing the featured image.', 'chargeforms' ), 400 ); } return Protected_Uploads::mediated_url( $moved['file'] ); } /** * The signature pad's hidden input holds a base64 PNG data URL (built * client-side by assets/js/frontend.js' initSignatures() as the visitor * draws) — this decodes it, verifies it's a genuine image (never trusts * the "data:image/png" prefix a tampered request could claim without * one), and saves it as a real uploaded file via wp_upload_bits(), the * same storage the rest of the codebase already uses for File Upload — * embedding tens of KB of base64 text directly in the entries table's * JSON response column on every submission isn't a reasonable trade for * skipping one file write. */ private function handle_signature_upload( array $field, $raw_value ) { $raw_value = is_string( $raw_value ) ? $raw_value : ''; if ( '' === $raw_value ) { if ( ! empty( $field['required'] ) ) { wp_die( esc_html( $this->required_message( $field ) ), 400 ); } return ''; } if ( strlen( $raw_value ) > self::MAX_SIGNATURE_BASE64_LENGTH ) { wp_die( esc_html__( 'That signature is too large. Please try again.', 'chargeforms' ), 400 ); } if ( ! preg_match( '/^data:image\/png;base64,(.+)$/', $raw_value, $matches ) ) { wp_die( esc_html__( 'That signature could not be read. Please try again.', 'chargeforms' ), 400 ); } $binary = base64_decode( $matches[1], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode if ( false === $binary || '' === $binary ) { wp_die( esc_html__( 'That signature could not be read. Please try again.', 'chargeforms' ), 400 ); } // getimagesizefromstring() only succeeds against real image bytes — // this is the actual guard against a crafted request sending // arbitrary binary data through a "data:image/png" wrapper. $image_info = @getimagesizefromstring( $binary ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $image_info || IMAGETYPE_PNG !== ( $image_info[2] ?? null ) ) { wp_die( esc_html__( 'That signature could not be read. Please try again.', 'chargeforms' ), 400 ); } require_once ABSPATH . 'wp-admin/includes/file.php'; $upload = wp_upload_bits( 'signature-' . uniqid() . '.png', null, $binary ); if ( ! empty( $upload['error'] ) ) { wp_die( esc_html__( 'There was a problem saving your signature. Please try again.', 'chargeforms' ), 400 ); } return esc_url_raw( $upload['url'] ); } /** * Site-wide anti-spam check against CleanTalk's API (Global Settings > * Security > CleanTalk) — applies to every form once an access key is * set, no per-form field needed. Returns true (allow), false (flagged * as spam), or null (not configured / API unreachable — never blocks a * real visitor just because CleanTalk itself is down). */ private function cleantalk_check( array $form, array $values ) { $access_key = Settings::get( 'cleantalk_access_key' ); if ( ! $access_key ) { return null; } $email = ''; foreach ( $form['fields'] as $field ) { if ( 'email' === $field['type'] && ! empty( $values[ $field['id'] ] ) ) { $email = $values[ $field['id'] ]; break; } } // Server-to-server anti-spam verification call, not an offloaded static asset. $response = wp_remote_post( 'https://moderate.cleantalk.org/api2.0', [ // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'timeout' => 5, 'body' => [ 'method_name' => 'check_newuser', 'auth_key' => $access_key, 'sender_email' => $email, 'sender_ip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', 'js_on' => 1, 'submit_time' => 5, ], ] ); if ( is_wp_error( $response ) ) { return null; // CleanTalk unreachable — fail open, don't block real visitors over a third-party outage. } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['allow'] ) ) { return null; } return (bool) $body['allow']; } /** * True if the form has no reCAPTCHA field, or if it does and Google's * siteverify API confirms the submitted token is real. This is the part * that actually matters — the widget alone is just a UI element a bot * could skip entirely; only a server-side check against Google enforces it. */ private function recaptcha_ok( array $form ) { if ( ! $this->form_has_field_type( $form, 'recaptcha' ) ) { return true; } $secret = Settings::get( 'recaptcha_secret_key' ); if ( ! $secret ) { return true; // Not configured — the field itself shows an admin-only notice about this. } $token = isset( $_POST['chargeforms_data'] ) ? sanitize_text_field( wp_unslash( $_POST['chargeforms_data'] )['g_recaptcha'] ?? '' ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing $token = ! empty( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : $token; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! $token ) { return false; } // Server-to-server anti-spam verification call, not an offloaded static asset. $response = wp_remote_post( 'https://www.google.com/recaptcha/api/siteverify', [ // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'timeout' => 8, 'body' => [ 'secret' => $secret, 'response' => $token, 'remoteip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', ], ] ); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); return ! empty( $body['success'] ); } /** Same shape as recaptcha_ok() — real server-side verification against hCaptcha's API, not just the widget. */ private function hcaptcha_ok( array $form ) { if ( ! $this->form_has_field_type( $form, 'hcaptcha' ) ) { return true; } $secret = Settings::get( 'hcaptcha_secret_key' ); if ( ! $secret ) { return true; // Not configured — the field itself shows an admin-only notice about this. } $token = isset( $_POST['h-captcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['h-captcha-response'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! $token ) { return false; } // Server-to-server anti-spam verification call, not an offloaded static asset. $response = wp_remote_post( 'https://api.hcaptcha.com/siteverify', [ // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'timeout' => 8, 'body' => [ 'secret' => $secret, 'response' => $token, ], ] ); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); return ! empty( $body['success'] ); } /** Same shape as recaptcha_ok() — real server-side verification against Cloudflare's siteverify API, not just the widget. */ private function turnstile_ok( array $form ) { if ( ! $this->form_has_field_type( $form, 'turnstile' ) ) { return true; } $secret = Settings::get( 'turnstile_secret_key' ); if ( ! $secret ) { return true; // Not configured — the field itself shows an admin-only notice about this. } $token = isset( $_POST['cf-turnstile-response'] ) ? sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! $token ) { return false; } // Server-to-server anti-spam verification call, not an offloaded static asset. $response = wp_remote_post( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [ // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'timeout' => 8, 'body' => [ 'secret' => $secret, 'response' => $token, 'remoteip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', ], ] ); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); return ! empty( $body['success'] ); } private function form_has_field_type( array $form, $type ) { foreach ( $form['fields'] as $field ) { if ( $type === $field['type'] ) { return true; } } return false; } /** Mirrors assets/js/frontend.js's computeCommerce() math-field branch exactly — same four operators, same divide-by-zero-is-0 guard, same round-to-2-decimals — so the server-authoritative value matches what the visitor saw, not just "some" recomputation. */ private function compute_math_field( array $field, array $values ) { $a = (float) ( $values[ $field['fieldA'] ?? '' ] ?? 0 ); $b = (float) ( $values[ $field['fieldB'] ?? '' ] ?? 0 ); $op = $field['operator'] ?? '+'; switch ( $op ) { case '-': $result = $a - $b; break; case '*': $result = $a * $b; break; case '/': $result = 0.0 !== $b ? $a / $b : 0; break; default: $result = $a + $b; } return (string) ( round( $result * 100 ) / 100 ); } /** * Returns true if the form has no Payment Method field (nothing to * verify), a Stripe PaymentIntent id string if a real, successful, * not-already-used payment for THIS form was confirmed, or false if * payment was required but couldn't be verified — callers must treat * false as a hard stop, never fall through to creating the entry. * * Three checks a submitter can't fake their way past: (1) status is * actually 'succeeded' per Stripe's own API, not just the client's say-so; * (2) the intent's metadata references this exact form, so a succeeded * intent captured on one form can't be replayed against a different one; * (3) the intent id has never been attached to an entry before, so the * same successful payment can't be replayed to mint a second free entry. */ /** * $values is the same sanitized array Entry::create() is about to * persist — the only server-authoritative record of what the visitor * actually submitted. Re-deriving the expected charge from it here * (rather than trusting whatever amount the PaymentIntent happened to * be created with) closes the gap a client could otherwise exploit by * requesting a cheap PaymentIntent (e.g. a small quantity or free * shipping option) via /create-payment-intent, then submitting the * form with different, more expensive selections — nothing previously * checked that the two matched. Amounts are compared in Stripe's own * unit (integer cents) to avoid float-equality false negatives. */ /** * Returns `true` (no payment field, nothing to verify), `false` (payment * failed/unverifiable — caller wp_die()s, no entry is ever created), or * an array `['gateway', 'transaction_id', 'amount']` on a genuinely * verified payment. Branches to the gateway the field is actually * configured for (`?? 'stripe'` — a form saved before PayPal existed has * no `gateway` value at all and must keep verifying exactly as it did * before). */ private function verify_payment( array $form, array $values ) { $payment_field = Commerce_Calculator::find_payment_method_field( $form['fields'] ?? [] ); if ( ! $payment_field ) { return true; } $gateway = $payment_field['gateway'] ?? 'stripe'; // Recurring billing branches to its own verification path — the // object being verified is a Subscription, not a one-time // PaymentIntent/Order, and success needs to create a // wp_chargeforms_subscriptions row, not just entry payment meta (see // the caller in handle() and verify_stripe_subscription()'s return // shape). Gated on Gateway_Manager::supports_subscriptions() rather // than a hardcoded 'stripe' check so this stays correct the moment // a second subscription-capable gateway registers itself. if ( 'recurring' === ( $payment_field['billingType'] ?? 'one_time' ) ) { return Gateway_Manager::supports_subscriptions( $gateway ) ? $this->verify_stripe_subscription( $form, $values, $payment_field ) : false; // Configured for recurring on a gateway that can't do it — reject rather than silently one-time-charging. } return 'paypal' === $gateway ? $this->verify_paypal_payment( $form, $values ) : $this->verify_stripe_payment( $form, $values ); } /** * Verifies a subscription created via RestApi::create_subscription() * and confirmed client-side (assets/js/stripe-payment.js's * confirmCardPayment()/confirmCardSetup() call). Same anti-tampering * shape as verify_stripe_payment(): status must reflect a genuinely * successful confirmation (not `incomplete`, which is what a * subscription sits at until the client actually confirms — trusting * the client's mere claim of success without this check would let a * submitter skip payment entirely), the form-id round-trips through * Stripe's own metadata, the subscription id can't be replayed onto a * second entry, and the per-cycle amount is recomputed from the * server's own just-validated `$values` and compared — in integer * cents — against what the subscription's own price item actually * charges, not whatever amount a tampered client might have * requested when the subscription was first created. * * Returns the same `['gateway','transaction_id','amount']` shape * verify_stripe_payment()/verify_paypal_payment() return (so the * existing gateway-agnostic entry-meta writes in handle() need no * special-casing), plus `is_subscription: true` and a `subscription` * array the caller uses to create the actual Subscription row. */ private function verify_stripe_subscription( array $form, array $values, array $payment_field ) { $subscription_id = isset( $_POST['chargeforms_subscription_id'] ) ? sanitize_text_field( wp_unslash( $_POST['chargeforms_subscription_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle() already verified the submit nonce above. if ( ! $subscription_id ) { return false; } if ( Subscription::get_by_gateway_id( 'stripe', $subscription_id ) ) { return false; } $subscription = Stripe_Gateway::retrieve_subscription( $subscription_id ); if ( is_wp_error( $subscription ) ) { return false; } // `incomplete`/`incomplete_expired` means the client-side confirm // never actually succeeded (or the submitter abandoned it) — // `trialing`/`active` are the only two states that mean "a payment // method was genuinely confirmed and attached," matching exactly // what create_subscription() can produce right after a successful // confirmCardPayment()/confirmCardSetup(). if ( ! in_array( $subscription['status'] ?? '', [ 'trialing', 'active' ], true ) ) { return false; } if ( (string) ( $subscription['metadata']['chargeforms_form_id'] ?? '' ) !== (string) $form['id'] ) { return false; } $amount = Commerce_Calculator::calculate_total( $form, $values ); $expected_cents = (int) round( $amount * 100 ); $actual_cents = (int) ( $subscription['items']['data'][0]['price']['unit_amount'] ?? -1 ); if ( $expected_cents !== $actual_cents ) { return false; } return [ 'gateway' => 'stripe', 'transaction_id' => $subscription_id, 'amount' => $amount, 'is_subscription' => true, 'subscription' => [ 'gateway_subscription_id' => $subscription_id, 'gateway_customer_id' => (string) ( $subscription['customer'] ?? '' ), 'plan_interval' => $subscription['items']['data'][0]['price']['recurring']['interval'] ?? ( $payment_field['billingInterval'] ?? 'month' ), 'status' => $subscription['status'], 'trial_end' => isset( $subscription['trial_end'] ) ? gmdate( 'Y-m-d H:i:s', $subscription['trial_end'] ) : null, 'current_period_end' => isset( $subscription['current_period_end'] ) ? gmdate( 'Y-m-d H:i:s', $subscription['current_period_end'] ) : null, ], ]; } private function verify_stripe_payment( array $form, array $values ) { $intent_id = isset( $_POST['chargeforms_payment_intent_id'] ) ? sanitize_text_field( wp_unslash( $_POST['chargeforms_payment_intent_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle() already verified the submit nonce above. if ( ! $intent_id ) { return false; } if ( Entry::meta_exists( 'stripe_payment_intent_id', $intent_id ) ) { return false; } $intent = Stripe_Gateway::retrieve_intent( $intent_id ); if ( is_wp_error( $intent ) ) { return false; } if ( ( $intent['status'] ?? '' ) !== 'succeeded' ) { return false; } if ( (string) ( $intent['metadata']['chargeforms_form_id'] ?? '' ) !== (string) $form['id'] ) { return false; } $amount = Stripe_Gateway::calculate_total( $form, $values ); $expected_cents = (int) round( $amount * 100 ); if ( $expected_cents !== (int) ( $intent['amount'] ?? -1 ) ) { return false; } return [ 'gateway' => 'stripe', 'transaction_id' => $intent_id, 'amount' => $amount ]; } /** * PayPal's real verification IS the capture — there's no separate * "confirm client-side, retrieve server-side" split the way Stripe has * (see Paypal_Gateway's class doc comment). Same anti-tampering shape as * Stripe's check otherwise: anti-replay on the transaction id, status * check, form-id round-trip via `custom_id`, and the captured amount * compared in integer cents against a server-side recomputation of the * just-validated submission — not the amount the client claims it paid. */ private function verify_paypal_payment( array $form, array $values ) { $order_id = isset( $_POST['chargeforms_paypal_order_id'] ) ? sanitize_text_field( wp_unslash( $_POST['chargeforms_paypal_order_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle() already verified the submit nonce above. if ( ! $order_id ) { return false; } if ( Entry::meta_exists( 'payment_transaction_id', $order_id ) ) { return false; } $captured = Paypal_Gateway::capture_order( $order_id ); if ( is_wp_error( $captured ) ) { return false; } if ( ( $captured['status'] ?? '' ) !== 'COMPLETED' ) { return false; } if ( (string) ( $captured['purchase_units'][0]['custom_id'] ?? '' ) !== (string) $form['id'] ) { return false; } $capture_record = $captured['purchase_units'][0]['payments']['captures'][0] ?? null; if ( ! $capture_record ) { return false; } $amount = Paypal_Gateway::calculate_total( $form, $values ); $expected_cents = (int) round( $amount * 100 ); $captured_cents = (int) round( (float) ( $capture_record['amount']['value'] ?? -1 ) * 100 ); if ( $expected_cents !== $captured_cents ) { return false; } return [ 'gateway' => 'paypal', 'transaction_id' => $capture_record['id'] ?? $order_id, 'amount' => $amount ]; } /** Max 5 submissions per IP per form per minute — blunts basic spam/abuse. */ private function is_rate_limited( $form_id ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $key = 'chargeforms_rl_' . md5( $ip . '_' . $form_id ); $count = (int) get_transient( $key ); if ( $count >= 5 ) { return true; } set_transient( $key, $count + 1, MINUTE_IN_SECONDS ); return false; } private function send_notification( array $form, array $values, $entry_id ) { self::send_entry_notification( $form, $values, $entry_id ); } /** Field types a notification email never lists — same exclusion list as the entries table/export columns (Admin\Entries_Page::NON_DATA_FIELD_TYPES, duplicated rather than shared across namespaces for one small stable array) — listing "reCAPTCHA: " or "Hidden Field: [technical value]" is exactly as confusing as an unexplained empty column would be in the table. */ const NOTIFICATION_SKIP_FIELD_TYPES = [ 'section_break', 'page_break', 'html', 'rich_text', 'hidden', 'recaptcha', 'hcaptcha', 'turnstile', 'product', 'price', 'subtotal', 'tax', 'discount', 'total', 'formula', 'section', 'row', 'column', 'container' ]; /** The default "New submission received: Field: value ..." dump every form gets until a subject/body is customized — also what {all_data} expands to when used inside a customized subject/body, so a site owner can still include it without giving up their own wording. */ private static function default_notification_body( array $form, array $values, $html = false ) { $body = __( 'New submission received:', 'chargeforms' ) . "\n\n"; foreach ( $form['fields'] as $field ) { if ( in_array( $field['type'], self::NOTIFICATION_SKIP_FIELD_TYPES, true ) ) { continue; } $field_value = $values[ $field['id'] ] ?? ''; // Checkbox groups and Address sub-fields store arrays — string- // concatenating an array is a PHP warning that (on hosts with // display_errors on) prints inline and corrupts the message, // same bug class as Admin\Entries_Page::display_value(). if ( is_array( $field_value ) ) { $field_value = implode( ', ', array_filter( array_map( 'strval', $field_value ), fn( $v ) => '' !== trim( $v ) ) ); } $body .= $field['label'] . ': ' . $field_value . "\n"; } // The rich text editor (v0.106.0) means a custom Message can now be // real HTML — the {all_data} tag has to render safely inside that, // not as a second plain-text block glued into an HTML document. // esc_html() here specifically defends against a submitted field // value itself containing HTML/script: this email lands in the SITE // OWNER's own inbox, not the submitter's, so a crafted value is a // real (if low-severity) injection vector once the message is HTML // instead of inert plain text. return $html ? nl2br( esc_html( $body ) ) : $body; } /** * Merge tags available in a customized notification Subject/Message: * {form_title}, {admin_email}, {all_data} (the full field dump above — * lets a customized message still include everything without giving up * its own wording/intro), and one {field.FIELD_ID} per real submittable * field. Unrecognized tags are left as literal text rather than * silently stripped, so a typo is visibly wrong instead of invisibly * losing text. */ private static function replace_notification_merge_tags( $text, array $form, array $values, $html = false, array $extra = [] ) { $tags = [ '{form_title}' => $html ? esc_html( $form['title'] ?? '' ) : ( $form['title'] ?? '' ), '{admin_email}' => get_option( 'admin_email' ), '{all_data}' => self::default_notification_body( $form, $values, $html ), ]; $tags = array_merge( $tags, $extra ); foreach ( $form['fields'] as $field ) { if ( empty( $field['id'] ) || in_array( $field['type'], self::NOTIFICATION_SKIP_FIELD_TYPES, true ) ) { continue; } $field_value = $values[ $field['id'] ] ?? ''; if ( is_array( $field_value ) ) { $field_value = implode( ', ', array_filter( array_map( 'strval', $field_value ), fn( $v ) => '' !== trim( $v ) ) ); } // esc_html() + nl2br() only in HTML mode — a multi-line Textarea // submission dropped raw into an HTML message would otherwise // lose its line breaks (browsers/email clients collapse literal // \n inside HTML) and, more importantly, a value containing real // HTML/script would execute in whatever reads this email; see // default_notification_body()'s matching comment for why this // specific injection path matters even though it's the site // owner's own inbox. $tags[ '{field.' . $field['id'] . '}' ] = $html ? nl2br( esc_html( $field_value ) ) : $field_value; } return strtr( (string) $text, $tags ); } /** * Resolves "Send To" when set to "A Form Field" (auto-reply to whatever * email address the submitter entered) — only ever used when the * resolved value actually validates as an email; a field left blank, a * deleted field, or a visitor who mistyped their address falls back to * the form's fixed notification email (never silently sends nowhere). */ private static function resolve_notification_send_to_field( array $notification, array $values ) { $field_id = $notification['send_to_field'] ?? ''; if ( '' === $field_id || ! isset( $values[ $field_id ] ) ) { return ''; } $value = is_array( $values[ $field_id ] ) ? '' : (string) $values[ $field_id ]; return is_email( $value ) ? $value : ''; } /** * the reference product parity: "Configure Routing" — a notification's recipient * depends on WHICH option the visitor picked on one designated field * (e.g. a "Department" dropdown routing to sales@/support@/billing@), * distinct from "An Email Field on This Form" above (which sends to * whatever address the visitor TYPED, not a lookup). An exact, * case-insensitive match against `routing_rules[].value`; no match * (field left blank, a since-removed choice, a rule that was never * configured for that option) returns '' so the caller's existing * fixed-email fallback applies — a routing miss never sends nowhere. */ private static function resolve_notification_routing( array $notification, array $values ) { $field_id = $notification['routing_field'] ?? ''; if ( '' === $field_id || ! isset( $values[ $field_id ] ) || empty( $notification['routing_rules'] ) ) { return ''; } $submitted = is_array( $values[ $field_id ] ) ? $values[ $field_id ] : [ $values[ $field_id ] ]; foreach ( $notification['routing_rules'] as $rule ) { if ( empty( $rule['email'] ) || ! isset( $rule['value'] ) || '' === $rule['value'] ) { continue; } foreach ( $submitted as $one ) { if ( 0 === strcasecmp( (string) $one, (string) $rule['value'] ) ) { return $rule['email']; } } } return ''; } /** * Falls back to the first Email-type field with a valid value when a * receipt's "Send To" field isn't explicitly configured — the common * case (a form with exactly one email field, the payer's own) needs no * setup at all, only a form with more than one email field needs the * explicit picker. */ private static function first_email_field_value( array $form, array $values ) { foreach ( $form['fields'] as $field ) { if ( 'email' !== ( $field['type'] ?? '' ) || empty( $field['id'] ) ) { continue; } $value = $values[ $field['id'] ] ?? ''; if ( is_email( is_array( $value ) ? '' : (string) $value ) ) { return (string) $value; } } return ''; } /** * Sends a payment receipt to the payer — separate from * send_entry_notification() above, which is the site owner's copy. * Opt-in per form (Form Settings > Payment Receipt), off by default, * and only ever fires when $payment_result is the real, verified * result verify_payment() returned for THIS submission — never * reconstructed from stored data, so there is no path to sending a * receipt for an amount that wasn't actually charged. */ private static function send_payment_receipt( array $form, array $values, $entry_id, array $payment_result ) { $settings = $form['settings'] ?: []; if ( empty( $settings['receipt_enabled'] ) ) { return; } $field_id = $settings['receipt_send_to_field'] ?? ''; $to = ''; if ( $field_id && isset( $values[ $field_id ] ) ) { $value = is_array( $values[ $field_id ] ) ? '' : (string) $values[ $field_id ]; $to = is_email( $value ) ? $value : ''; } $to = $to ?: self::first_email_field_value( $form, $values ); if ( ! $to ) { return; // No email to send to — never guess, never fall back to the site owner's own address for a "your receipt" email. } $currency = strtoupper( ( 'paypal' === $payment_result['gateway'] ? Settings::get( 'paypal_currency', 'usd' ) : Settings::get( 'stripe_currency', 'usd' ) ) ); $extra = [ '{payment_amount}' => number_format_i18n( (float) $payment_result['amount'], 2 ) . ' ' . $currency, '{payment_date}' => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ), '{payment_gateway}' => 'paypal' === $payment_result['gateway'] ? __( 'PayPal', 'chargeforms' ) : __( 'Card', 'chargeforms' ), '{transaction_id}' => (string) $payment_result['transaction_id'], ]; $subject = trim( (string) ( $settings['receipt_subject'] ?? '' ) ) ?: sprintf( /* translators: %s: form title */ __( 'Your receipt for %s', 'chargeforms' ), $form['title'] ); $subject = self::replace_notification_merge_tags( $subject, $form, $values, false, $extra ); $message = trim( (string) ( $settings['receipt_message'] ?? '' ) ) ?: self::default_receipt_body(); $body = self::replace_notification_merge_tags( $message, $form, $values, true, $extra ); // Recurring payments only — Subscription_Portal::manage_url() needs // a real wp_chargeforms_subscriptions row id, which only exists for // a subscription (see the capture right after Subscription::create() // above); a one-time payment has nothing to "manage" after the fact. if ( ! empty( $payment_result['is_subscription'] ) && ! empty( $payment_result['subscription_row_id'] ) ) { $manage_url = Subscription_Portal::manage_url( $payment_result['subscription_row_id'] ); $body .= '

' . esc_html__( 'Manage your subscription', 'chargeforms' ) . '

'; } $footer = trim( (string) Settings::get( 'email_footer_text' ) ); if ( '' !== $footer ) { $body .= '

' . nl2br( esc_html( $footer ) ); } wp_mail( $to, $subject, $body, [ 'Content-Type: text/html; charset=UTF-8' ] ); } /** Default receipt body when a form hasn't customized one — deliberately plain and payment-focused, not the full field dump default_notification_body() gives the site owner's copy; a payer wants "here's what I paid," not every field they filled in echoed back. */ private static function default_receipt_body() { return '

' . esc_html__( 'Thank you for your payment.', 'chargeforms' ) . '

' . '

' . esc_html__( 'Amount:', 'chargeforms' ) . ' {payment_amount}
' . esc_html__( 'Date:', 'chargeforms' ) . ' {payment_date}
' . esc_html__( 'Payment method:', 'chargeforms' ) . ' {payment_gateway}
' . esc_html__( 'Reference:', 'chargeforms' ) . ' {transaction_id}

'; } /** * Sends every enabled owner-notification configured on this form — * feature-parity gap found live (user comparison screenshot): a form * used to support exactly one notification, driven by flat * `notification_*` settings keys; Notification_List::from_settings() * now returns either the real `settings['notifications']` list or a * single synthesized row from those same legacy keys, so an * un-migrated form keeps sending its one notification unchanged. * * $to_override (the Entries admin "Resend Email Notification" action) * intentionally still sends only ONE email, using the first * notification as the template but to the override address — fanning * every configured notification out to one admin-chosen address on a * manual resend would be surprising, not helpful. * * $ignore_conditions is true only for Form_Settings_Page::handle_send_test() * ("Send Test Email" — documented as sending EVERY enabled notification * so a site owner can preview each one), since its placeholder * "(sample value)" strings would almost never satisfy a real per- * notification Conditional Logic rule, and a test button silently * sending nothing because sample data didn't match a condition would * look like the feature was broken rather than working as designed. A * real submission (this parameter's default) always respects it. */ public static function send_entry_notification( array $form, array $values, $entry_id, $to_override = '', $ignore_conditions = false ) { $settings = $form['settings'] ?: []; $notifications = Notification_List::from_settings( $settings ); if ( $to_override ) { self::send_one_notification( $form, $values, $entry_id, $notifications[0], $to_override ); return; } foreach ( $notifications as $notification ) { if ( ! empty( $notification['enabled'] ) && ( $ignore_conditions || self::notification_conditions_met( $notification, $values ) ) ) { self::send_one_notification( $form, $values, $entry_id, $notification ); } } } /** * the reference product parity: per-notification Conditional Logic — a specific * notification (e.g. "Sales Team Alert") only fires when the submitted * values match its own rule group, independent of every other * notification on the same form. Reuses the exact same 'group' LogicNode * shape as Restrictions::check_validation() and field-level Conditional * Logic — one engine, a third caller, not a parallel implementation. * A notification with the feature off (the overwhelming majority — this * defaults to false and no existing form has ever set it) always * returns true here, so nothing changes for any notification that * doesn't use it. */ private static function notification_conditions_met( array $notification, array $values ) { if ( empty( $notification['condition_enabled'] ) || empty( $notification['condition_rules'] ) ) { return true; } $children = []; foreach ( $notification['condition_rules'] as $rule ) { if ( empty( $rule['field_id'] ) ) { continue; } $children[] = [ 'type' => 'condition', 'source' => 'field', 'ref' => $rule['field_id'], 'operator' => $rule['operator'] ?? 'is', 'value' => $rule['value'] ?? '', ]; } if ( ! $children ) { return true; } $group = [ 'type' => 'group', 'combinator' => 'any' === ( $notification['condition_match'] ?? 'all' ) ? 'OR' : 'AND', 'not' => false, 'children' => $children, ]; return Evaluator::evaluate_logic_node( $group, [ 'field_values' => $values ] ); } private static function send_one_notification( array $form, array $values, $entry_id, array $notification, $to_override = '' ) { $settings = $form['settings'] ?: []; $send_to_type = $notification['send_to_type'] ?? 'fixed'; if ( $to_override ) { $to = $to_override; } elseif ( 'field' === $send_to_type ) { $to = self::resolve_notification_send_to_field( $notification, $values ) ?: ( $notification['email'] ?: Settings::get( 'notification_email' ) ); } elseif ( 'routing' === $send_to_type ) { $to = self::resolve_notification_routing( $notification, $values ) ?: ( $notification['email'] ?: Settings::get( 'notification_email' ) ); } else { $to = $notification['email'] ?: Settings::get( 'notification_email' ); } // Subject stays plain text always — email subject lines aren't HTML // in any client, so this call never passes $html=true. $subject = trim( (string) ( $notification['subject'] ?? '' ) ); $subject = '' !== $subject ? self::replace_notification_merge_tags( $subject, $form, $values ) : sprintf( /* translators: %s: form title */ __( 'New submission: %s', 'chargeforms' ), $form['title'] ); // A custom Message now comes from a real rich text editor (v0.106.0), // so it's genuine HTML — $is_html governs both which merge-tag // escaping mode runs (see replace_notification_merge_tags()'s own // comment) and whether the Content-Type header below is set. The // no-custom-message fallback stays plain text exactly as before — // zero behavior change for any form not using a custom message. $message = trim( (string) ( $notification['message'] ?? '' ) ); $is_html = '' !== $message; $body = $is_html ? self::replace_notification_merge_tags( $message, $form, $values, true ) : self::default_notification_body( $form, $values ); $footer = trim( (string) Settings::get( 'email_footer_text' ) ); if ( '' !== $footer ) { $body .= $is_html ? '

' . nl2br( esc_html( $footer ) ) : "\n\n" . $footer; } $headers = []; if ( $is_html ) { $headers[] = 'Content-Type: text/html; charset=UTF-8'; } if ( ! empty( $notification['cc'] ) ) { $headers[] = 'Cc: ' . $notification['cc']; } if ( ! empty( $notification['bcc'] ) ) { $headers[] = 'Bcc: ' . $notification['bcc']; } $attachments = ! empty( $notification['attach_uploads'] ) ? self::collect_upload_attachments( $form, $values ) : []; // Notifications::send() logs this attempt and schedules a WP-Cron // retry on failure (roadmap issue #20) — previously a bare wp_mail() // call with no record of which form/entry it was for and no retry // at all beyond a generic Activity Log line. Notifications::send( $form['id'], $entry_id, $to, $subject, $body, $headers, $attachments ); } /** * "Media File Attachments" (roadmap: the reference product comparison, * "Notifications") — scoped here to files the visitor actually * uploaded on THIS submission (File Upload / Image Upload / Signature), * rather than a separately-configured static attachment, since that's * the concrete, bounded version of "attach a file to the notification" * ChargeForms can already support without a new admin-side file picker. * Only ever attaches a path that's both under the real uploads baseurl * AND exists on disk — never trusts a stored value as a filesystem path * on its own. */ private static function collect_upload_attachments( array $form, array $values ) { $upload_dir = wp_upload_dir(); $paths = []; foreach ( $form['fields'] as $field ) { if ( ! in_array( $field['type'], [ 'file', 'image_upload', 'featured_image', 'signature' ], true ) ) { continue; } $url = $values[ $field['id'] ] ?? ''; if ( ! is_string( $url ) || '' === $url || 0 !== strpos( $url, $upload_dir['baseurl'] ) ) { continue; } $path = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $url ); if ( file_exists( $path ) ) { $paths[] = $path; } } return $paths; } private function redirect_back( $form_id, ?array $form = null, array $values = [], $pending = false ) { $settings = $form['settings'] ?? []; // Conditional Confirmations (the reference product parity) — never checked for // a pending (double opt-in) submission, same reasoning as the type // override below: the visitor hasn't actually finished yet, so any // custom "you're done" confirmation would be misleading. $confirmation_index = $pending ? null : self::resolve_conditional_confirmation( $form, $values ); $matched = null !== $confirmation_index ? $settings['confirmations'][ $confirmation_index ] : null; // A pending (double opt-in) submission always gets the distinct // "check your inbox" notice, even when the form's own confirmation // type is "Redirect to a URL" — the visitor hasn't actually finished // yet, so sending them to a normal success/thank-you destination // would be misleading. $confirmation_type = $matched['type'] ?? ( $settings['confirmation_type'] ?? 'message' ); $wants_redirect = ! $pending && 'redirect' === $confirmation_type; $redirect_url = $matched['redirect_url'] ?? ( $settings['redirect_url'] ?? '' ); $redirect_query_string = $matched ? true : ! empty( $settings['redirect_query_string'] ); // A matched row has no query-string toggle of its own — always on, same as passing values through is always useful for a redirect a condition specifically chose. $redirect_url = $wants_redirect && ! empty( $redirect_url ) ? $redirect_url : ''; if ( $redirect_url && wp_http_validate_url( $redirect_url ) ) { if ( $redirect_query_string ) { $redirect_url = add_query_arg( self::redirect_query_args( $values ), $redirect_url ); } wp_safe_redirect( esc_url_raw( $redirect_url ) ); exit; } $referer = wp_get_referer() ?: home_url( '/' ); $args = [ 'chargeforms_submitted' => $form_id ]; if ( $pending ) { $args['chargeforms_pending'] = 1; } elseif ( null !== $confirmation_index ) { // Shortcode::render_form_html() looks this index up fresh from // the form's OWN settings when it renders the notice — the // message text itself never travels through the URL, only which // row to use. $args['chargeforms_confirmation'] = $confirmation_index; } $redirect = add_query_arg( $args, $referer ); wp_safe_redirect( $redirect ); exit; } /** * Checked in row order — the first row whose condition is true wins. * Built as the exact same LogicNode shape (Includes\Logic\Evaluator) * field-level conditional logic already uses, so this is one more * caller of that one engine, not a second condition-matching * implementation. Builtin operators/data sources are already * registered by the server-side visibility check earlier in * handle_submit() (Evaluator::evaluate_rules() self-registers them on * first use each request), so this doesn't need to register them again. * * @return int|null The matching row's index into $form['settings']['confirmations'], or null. */ private static function resolve_conditional_confirmation( ?array $form, array $values ) { $rows = $form['settings']['confirmations'] ?? []; if ( empty( $rows ) ) { return null; } foreach ( $rows as $index => $row ) { if ( empty( $row['field_id'] ) ) { continue; } $node = [ 'type' => 'condition', 'source' => 'field', 'ref' => $row['field_id'], 'operator' => $row['operator'] ?? 'is', 'value' => $row['value'] ?? '', ]; if ( Evaluator::evaluate_logic_node( $node, [ 'field_values' => $values ] ) ) { return $index; } } return null; } /** * "Pass Field Data Via Query String" (roadmap: the reference product comparison, * "Confirmations") — appends submitted values to a redirect confirmation * URL so the destination page can personalize itself or prefill fields, * e.g. .../thank-you?email=a@b.com. Only scalar values are passed — * array-valued fields (checkbox groups, Address, Repeater) are skipped * rather than serialized into a query string in some arbitrary shape. */ private static function redirect_query_args( array $values ) { $args = []; foreach ( $values as $field_id => $value ) { if ( is_string( $value ) && '' !== $value ) { $args[ $field_id ] = $value; } } return $args; } }