' . 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 .= '' . 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}