| @@ -34,11 +34,48 @@ | ||
| 34 | 34 | */ |
| 35 | 35 | public function __construct() { |
| 36 | 36 | add_action( 'wp_ajax_suredonation_submit_donation', [ $this, 'handle_donation_submission' ] ); |
| 37 | 37 | add_action( 'wp_ajax_nopriv_suredonation_submit_donation', [ $this, 'handle_donation_submission' ] ); |
| 38 | + | |
| 39 | + // Runtime gateway configuration, read by the form script when it initialises. | |
| 40 | + add_action( 'wp_ajax_suredonation_gateway_config', [ $this, 'get_gateway_config' ] ); | |
| 41 | + add_action( 'wp_ajax_nopriv_suredonation_gateway_config', [ $this, 'get_gateway_config' ] ); | |
| 38 | 42 | } |
| 39 | 43 | |
| 40 | 44 | /** |
| 45 | + * Serve the gateway configuration for a donation form. | |
| 46 | + * | |
| 47 | + * Public read, fetched by the form script when it initialises so the Stripe | |
| 48 | + * key, PayPal SDK URL, payment mode and currency reflect the settings as | |
| 49 | + * they are now — not as they were when a page cache stored the form. It | |
| 50 | + * goes through admin-ajax, which page caches leave alone by default and | |
| 51 | + * which keeps working on sites that restrict the REST API for visitors. | |
| 52 | + * | |
| 53 | + * @return void | |
| 54 | + * @since 1.5.1 | |
| 55 | + */ | |
| 56 | + public function get_gateway_config() { | |
| 57 | + // Throttle abuse as every other public endpoint does. The ceiling is | |
| 58 | + // far above the default because this fires once per form page view, | |
| 59 | + // not per donor action, and many visitors can legitimately share one | |
| 60 | + // address (an office or campus NAT). When it trips, the scripts fall | |
| 61 | + // back to the rendered configuration rather than failing. | |
| 62 | + if ( ! Helper::check_rate_limit( 'gateway_config', 120 ) ) { | |
| 63 | + wp_send_json_error( [ 'message' => __( 'Too many requests. Please wait a moment and try again.', 'suredonation' ) ], 429 ); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read of non-secret data; nothing changes state, and a nonce would be cached with the page it is meant to protect. | |
| 67 | + $form_id = isset( $_GET['form_id'] ) ? absint( $_GET['form_id'] ) : 0; | |
| 68 | + | |
| 69 | + // Freshness is the whole point of this response. admin-ajax already | |
| 70 | + // sends these, but an edge cache with a blanket rule would not care, | |
| 71 | + // so the guarantee is made explicit rather than inherited. | |
| 72 | + nocache_headers(); | |
| 73 | + | |
| 74 | + wp_send_json_success( Payment_Helper::get_frontend_gateway_config( $form_id ) ); | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** | |
| 41 | 78 | * Handle donation form submission. |
| 42 | 79 | * |
| 43 | 80 | * @return void |
| 44 | 81 | * @since 0.0.1 |
| @@ -91,21 +128,26 @@ | ||
| 91 | 128 | } |
| 92 | 129 | } |
| 93 | 130 | |
| 94 | 131 | // Get form data. |
| 95 | - $amount = isset( $_POST['amount'] ) ? floatval( $_POST['amount'] ) : 0; | |
| 96 | - $cover_fees = isset( $_POST['cover_fees'] ) && 'true' === $_POST['cover_fees']; | |
| 97 | - $is_anonymous = isset( $_POST['is_anonymous'] ) ? true : false; | |
| 98 | - $donor_name = $is_anonymous ? __( 'Anonymous', 'suredonation' ) : sanitize_text_field( wp_unslash( $_POST['donor_name'] ?? '' ) ); | |
| 99 | - $donor_email = sanitize_email( wp_unslash( $_POST['donor_email'] ?? '' ) ); | |
| 100 | - $donor_comment = sanitize_textarea_field( wp_unslash( $_POST['donor_comment'] ?? '' ) ); | |
| 132 | + $amount = isset( $_POST['amount'] ) ? floatval( $_POST['amount'] ) : 0; | |
| 133 | + $cover_fees = isset( $_POST['cover_fees'] ) && 'true' === $_POST['cover_fees']; | |
| 134 | + // The anonymous flag is display-only: the donor's real name is stored as | |
| 135 | + // usual below and only public surfaces mask it. | |
| 136 | + $donor_name = sanitize_text_field( wp_unslash( $_POST['donor_name'] ?? '' ) ); | |
| 137 | + $donor_email = sanitize_email( wp_unslash( $_POST['donor_email'] ?? '' ) ); | |
| 101 | 138 | |
| 102 | 139 | // Get form_id and block_id for amount validation. |
| 103 | - $form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; | |
| 140 | + $form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; | |
| 141 | + $is_anonymous = Payment_Helper::get_submitted_is_anonymous( $form_id ); | |
| 104 | 142 | // Derive the donor phone from the validated mapped field, not a separate |
| 105 | 143 | // unvalidated $_POST['donor_phone'] (see Payment_Helper::get_mapped_donor_phone). |
| 106 | 144 | $donor_phone = Payment_Helper::get_mapped_donor_phone( $form_id ); |
| 107 | - $block_id = isset( $_POST['block_id'] ) ? sanitize_text_field( wp_unslash( $_POST['block_id'] ) ) : ''; | |
| 145 | + // Likewise derive the comment from the form's Donor Comment field rather | |
| 146 | + // than an unvalidated $_POST['donor_comment'] (see | |
| 147 | + // Payment_Helper::get_mapped_donor_comment). | |
| 148 | + $donor_comment = Payment_Helper::get_mapped_donor_comment( $form_id ); | |
| 149 | + $block_id = isset( $_POST['block_id'] ) ? sanitize_text_field( wp_unslash( $_POST['block_id'] ) ) : ''; | |
| 108 | 150 | |
| 109 | 151 | // Validate required fields. |
| 110 | 152 | if ( $amount <= 0 ) { |
| 111 | 153 | wp_send_json_error( __( 'Invalid donation amount', 'suredonation' ) ); |
| @@ -118,21 +160,23 @@ | ||
| 118 | 160 | |
| 119 | 161 | // Validate field values + amount against block configuration. Pass the |
| 120 | 162 | // offline gateway so the Stripe-only minimum floor is not applied here. |
| 121 | 163 | $currency = Payment_Helper::get_currency(); |
| 122 | - $validation_result = Payment_Helper::validate_submission( Payment_Helper::get_submitted_fields(), $amount, $currency, $form_id, $block_id, 'offline' ); | |
| 164 | + $validation_result = Payment_Helper::validate_submission( Payment_Helper::get_submitted_fields(), $amount, $currency, $form_id, $block_id, 'offline', 'one-time' ); | |
| 123 | 165 | if ( ! $validation_result['valid'] ) { |
| 124 | 166 | wp_send_json_error( esc_html( $validation_result['message'] ) ); |
| 125 | 167 | } |
| 126 | 168 | |
| 127 | - if ( ! $is_anonymous ) { | |
| 128 | - if ( empty( $donor_name ) ) { | |
| 129 | - wp_send_json_error( __( 'Donor name is required', 'suredonation' ) ); | |
| 130 | - } | |
| 131 | - if ( empty( $donor_email ) || ! is_email( $donor_email ) ) { | |
| 132 | - wp_send_json_error( __( 'Valid email address is required', 'suredonation' ) ); | |
| 133 | - } | |
| 169 | + // Name and email are required whether or not the donation is anonymous — | |
| 170 | + // the flag only masks the name on public surfaces, so there still has to | |
| 171 | + // be a real name to mask (matches the gateway handlers, which validate | |
| 172 | + // these through validate_submission() regardless of the flag). | |
| 173 | + if ( empty( $donor_name ) ) { | |
| 174 | + wp_send_json_error( __( 'Donor name is required', 'suredonation' ) ); | |
| 134 | 175 | } |
| 176 | + if ( empty( $donor_email ) || ! is_email( $donor_email ) ) { | |
| 177 | + wp_send_json_error( __( 'Valid email address is required', 'suredonation' ) ); | |
| 178 | + } | |
| 135 | 179 | |
| 136 | 180 | // Server-side fee calculation — ignore client-supplied base_amount to prevent manipulation. |
| 137 | 181 | $base_amount = $amount; |
| 138 | 182 | $fees_covered = 0; |
| @@ -150,13 +194,11 @@ | ||
| 150 | 194 | $fees_covered = 0; |
| 151 | 195 | } |
| 152 | 196 | } |
| 153 | 197 | |
| 154 | - // Get or create donor. | |
| 155 | - $donor_id = 0; | |
| 156 | - if ( ! empty( $donor_email ) ) { | |
| 157 | - $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone ); | |
| 158 | - } | |
| 198 | + // Get or create donor. The email is validated as non-empty above, so | |
| 199 | + // there is no guard here — anonymous or not, this path always has one. | |
| 200 | + $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone ); | |
| 159 | 201 | |
| 160 | 202 | // Get payment mode. |
| 161 | 203 | $payment_mode = 'live'; |
| 162 | 204 | if ( class_exists( 'SureDonation\Inc\Payments\Payment_Helper' ) ) { |
| @@ -165,26 +207,27 @@ | ||
| 165 | 207 | |
| 166 | 208 | // Create donation in database. |
| 167 | 209 | $donation_id = Donations::add( |
| 168 | 210 | [ |
| 169 | - 'campaign_id' => $campaign_id, | |
| 170 | - 'donor_id' => $donor_id ? $donor_id : 0, | |
| 171 | - 'amount' => number_format( $base_amount, 2, '.', '' ), | |
| 172 | - 'fees_covered' => number_format( $fees_covered, 2, '.', '' ), | |
| 173 | - 'currency' => Payment_Helper::get_currency(), | |
| 174 | - 'gateway' => 'manual', | |
| 175 | - 'payment_status' => 'pending', | |
| 176 | - 'payment_mode' => $payment_mode, | |
| 177 | - 'donor_name' => $donor_name, | |
| 178 | - 'donor_email' => $donor_email, | |
| 179 | - 'donor_phone' => $donor_phone, | |
| 180 | - 'is_anonymous' => $is_anonymous ? 1 : 0, | |
| 181 | - 'donation_type' => 'one-time', | |
| 182 | - 'donor_comment' => $donor_comment, | |
| 183 | - 'form_id' => $form_id, | |
| 184 | - 'ip_address' => Helper::get_client_ip(), | |
| 185 | - 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', | |
| 186 | - 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', | |
| 211 | + 'campaign_id' => $campaign_id, | |
| 212 | + 'donor_id' => $donor_id ? $donor_id : 0, | |
| 213 | + 'amount' => number_format( $base_amount, 2, '.', '' ), | |
| 214 | + 'fees_covered' => number_format( $fees_covered, 2, '.', '' ), | |
| 215 | + 'currency' => Payment_Helper::get_currency(), | |
| 216 | + 'gateway' => 'manual', | |
| 217 | + 'payment_status' => 'pending', | |
| 218 | + 'payment_mode' => $payment_mode, | |
| 219 | + 'donor_name' => $donor_name, | |
| 220 | + 'donor_email' => $donor_email, | |
| 221 | + 'donor_phone' => $donor_phone, | |
| 222 | + 'is_anonymous' => $is_anonymous ? 1 : 0, | |
| 223 | + 'donation_type' => 'one-time', | |
| 224 | + 'donor_comment' => $donor_comment, | |
| 225 | + 'donor_comment_status' => Donations::initial_comment_status( $donor_comment ), | |
| 226 | + 'form_id' => $form_id, | |
| 227 | + 'ip_address' => Helper::get_client_ip(), | |
| 228 | + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', | |
| 229 | + 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', | |
| 187 | 230 | ] |
| 188 | 231 | ); |
| 189 | 232 | |
| 190 | 233 | if ( ! $donation_id ) { |
| @@ -207,8 +250,13 @@ | ||
| 207 | 250 | 'amount' => $base_amount, |
| 208 | 251 | 'fees_covered' => $fees_covered, |
| 209 | 252 | 'currency' => Payment_Helper::get_currency(), |
| 210 | 253 | 'gateway' => 'manual', |
| 254 | + // One-time regardless of the block's configured type, and intentionally | |
| 255 | + // unguarded: this handler has no remaining caller in src/, writes a | |
| 256 | + // record rather than moving money, and gating it on payment type would | |
| 257 | + // reject manual entries on recurring forms. Whether it should still be | |
| 258 | + // registered at all is the better question, tracked separately. | |
| 211 | 259 | 'donation_type' => 'one-time', |
| 212 | 260 | ]; |
| 213 | 261 | |
| 214 | 262 | Email_Handler::send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id ); |