PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.10.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.10.1
2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 All 96 releases
sureforms / inc / form-submit.php
form-submit.php
1,400 lines 51.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sureforms Submit Class file.
4 *
5 * @package sureforms.
6 * @since 0.0.1
7 */
8
9 namespace SRFM\Inc;
10
11 use SRFM\Inc\Database\Tables\Entries;
12 use SRFM\Inc\Email\Email_Template;
13 use SRFM\Inc\Lib\Browser\Browser;
14 use SRFM\Inc\Traits\Get_Instance;
15 use WP_Error;
16 use WP_REST_Server;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit; // Exit if accessed directly.
20 }
21
22 if ( ! function_exists( 'wp_handle_upload' ) ) {
23 require_once ABSPATH . 'wp-admin/includes/file.php';
24 }
25
26 /**
27 * Sureforms Submit Class.
28 *
29 * @since 0.0.1
30 */
31 class Form_Submit {
32 use Get_Instance;
33
34 /**
35 * Namespace.
36 *
37 * @var string
38 */
39 protected $namespace = 'sureforms/v1';
40
41 /**
42 * Addresses.
43 *
44 * @var string
45 * @since 1.6.1
46 */
47 private $addresses = '';
48
49 /**
50 * Constructor
51 *
52 * @since 0.0.1
53 */
54 public function __construct() {
55 add_action( 'rest_api_init', [ $this, 'register_custom_endpoint' ] );
56 add_action( 'wp_ajax_validation_ajax_action', [ $this, 'field_unique_validation' ] );
57 add_action( 'wp_ajax_nopriv_validation_ajax_action', [ $this, 'field_unique_validation' ] );
58 // for quick action bar.
59 add_action( 'wp_ajax_srfm_global_update_allowed_block', [ $this, 'srfm_global_update_allowed_block' ] );
60 add_action( 'wp_ajax_srfm_global_sidebar_enabled', [ $this, 'srfm_global_sidebar_enabled' ] );
61 }
62
63 /**
64 * Add custom API Route submit-form
65 *
66 * @return void
67 * @since 0.0.1
68 */
69 public function register_custom_endpoint() {
70 register_rest_route(
71 $this->namespace,
72 '/submit-form',
73 [
74 'methods' => WP_REST_Server::EDITABLE,
75 'callback' => [ $this, 'handle_form_submission' ],
76 'permission_callback' => [ $this, 'submit_form_permissions_check' ],
77 ]
78 );
79 }
80
81 /**
82 * Check whether a given request has permission to submit the form.
83 *
84 * Validates the HMAC-based submission token embedded in the page at render
85 * time. Tokens remain valid for up to 48 hours (four 12-hour windows), so
86 * they survive cached-page scenarios without any browser-side refresh call.
87 *
88 * @param \WP_REST_Request $request Incoming REST request.
89 * @since 2.6.0
90 * @return WP_Error|bool
91 */
92 public function submit_form_permissions_check( $request ) {
93 $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) );
94 $form_id = absint( $request->get_param( 'form-id' ) );
95
96 if ( ! Submit_Token::verify( $token, $form_id ) ) {
97 return new WP_Error(
98 'srfm_token_invalid',
99 __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
100 [ 'status' => 403 ]
101 );
102 }
103
104 return true;
105 }
106
107 /**
108 * Check whether a given request has permission access route.
109 *
110 * @since 0.0.1
111 * @return WP_Error|bool
112 */
113 public function permissions_check() {
114 if ( ! Helper::current_user_can() ) {
115 return new WP_Error( 'rest_forbidden', __( 'Sorry, you do not have permission to access this resource.', 'sureforms' ), [ 'status' => rest_authorization_required_code() ] );
116 }
117 return true;
118 }
119
120 /**
121 * Validate Turnstile token
122 *
123 * @param string $secret_key Turnstile token.
124 * @param string|false $response Response.
125 * @param string|false $remote_ip Remote IP.
126 * @return array<mixed>|mixed Result of the validation.
127 */
128 public static function validate_turnstile_token( $secret_key, $response, $remote_ip ) {
129
130 if ( empty( $secret_key ) || ! is_string( $secret_key ) ) {
131 return [
132 'success' => false,
133 'error' => __( 'Cloudflare Turnstile secret key is invalid.', 'sureforms' ),
134 ];
135 }
136
137 if ( empty( $response ) ) {
138 return [
139 'success' => false,
140 'error' => __( 'Cloudflare Turnstile response is missing.', 'sureforms' ),
141 ];
142 }
143
144 $body = [
145 'secret' => $secret_key,
146 'response' => $response,
147 'remoteip' => $remote_ip,
148 ];
149
150 $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
151
152 $args = [
153 'body' => $body,
154 'timeout' => 15,
155 ];
156
157 $response = wp_remote_post( $url, $args );
158
159 if ( is_wp_error( $response ) ) {
160 $error_message = $response->get_error_message();
161 return [
162 'success' => false,
163 'error' => $error_message,
164 ];
165 }
166
167 return json_decode( wp_remote_retrieve_body( $response ), true );
168 }
169
170 /**
171 * Validate hCaptcha token
172 *
173 * @param string $secret_key hCaptcha token.
174 * @param string|false $response Response.
175 * @param string|false $remote_ip Remote IP.
176 * @since 0.0.5
177 * @return array<mixed>|mixed Result of the validation.
178 */
179 public static function validate_hcaptcha_token( $secret_key, $response, $remote_ip ) {
180
181 if ( empty( $secret_key ) || ! is_string( $secret_key ) ) {
182 return [
183 'success' => false,
184 'error' => __( 'hCaptcha secret key is invalid.', 'sureforms' ),
185 ];
186 }
187
188 if ( empty( $response ) ) {
189 return [
190 'success' => false,
191 'error' => __( 'hCaptcha response is missing.', 'sureforms' ),
192 ];
193 }
194
195 $body = [
196 'secret' => $secret_key,
197 'response' => $response,
198 'remoteip' => $remote_ip,
199 ];
200
201 $url = 'https://api.hcaptcha.com/siteverify';
202
203 $args = [
204 'body' => $body,
205 'timeout' => 15,
206 ];
207
208 $response = wp_remote_post( $url, $args );
209
210 if ( is_wp_error( $response ) ) {
211 $error_message = $response->get_error_message();
212 return [
213 'success' => false,
214 'error' => $error_message,
215 ];
216 }
217
218 return json_decode( wp_remote_retrieve_body( $response ), true );
219 }
220
221 /**
222 * Handle Form Submission
223 *
224 * @param \WP_REST_Request $request Request object or array containing form data.
225 * @since 0.0.1
226 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
227 */
228 public function handle_form_submission( $request ) {
229 $form_data = Helper::sanitize_by_field_type( $request->get_params() );
230
231 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
232 wp_send_json_error( [ 'message' => __( 'Form data is not found.', 'sureforms' ) ] );
233 }
234
235 if ( empty( $form_data['form-id'] ) ) {
236 wp_send_json_error(
237 [
238 'message' => __( 'Form ID is missing.', 'sureforms' ),
239 'position' => 'header',
240 ]
241 );
242 }
243
244 $current_form_id = $form_data['form-id'];
245
246 /**
247 * If someone tries to access the form submit endpoint directly, we need to check if the form is restricted.
248 * If a form is loaded in a browser window and the limit exceeds then the form will not be submitted.
249 */
250 $form_id = Helper::get_integer_value( $current_form_id );
251 if ( Form_Restriction::is_form_restricted( $form_id ) ) {
252 $form_restriction = Form_Restriction::get_form_restriction_setting( $form_id );
253
254 // Get the scheduling state and appropriate message.
255 $scheduling_state = Form_Restriction::get_form_scheduling_state( $form_restriction );
256 $form_restriction_message = Form_Restriction::get_restriction_message_by_state( $scheduling_state, $form_restriction );
257
258 $form_restriction_message = apply_filters( 'srfm_form_restriction_message', $form_restriction_message, $form_id, $form_restriction );
259
260 wp_send_json_error(
261 [
262 'message' => $form_restriction_message,
263 ]
264 );
265 }
266
267 if ( apply_filters( 'srfm_additional_restriction_check', false, $form_id, $form_data ) ) {
268 wp_send_json_error(
269 [
270 'message' => apply_filters( 'srfm_additional_restriction_message', __( 'You do not have permission to submit this form.', 'sureforms' ), $form_id, $form_data ),
271 ]
272 );
273 }
274
275 // Check whether the form is valid.
276 if ( ! Helper::is_valid_form( $current_form_id ) ) {
277 wp_send_json_error(
278 [
279 'code' => 'srfm_invalid_form_id',
280 'message' => __( 'This form is no longer available.', 'sureforms' ),
281 ]
282 );
283 }
284
285 $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id );
286
287 if ( ! empty( $validated_form_data ) ) {
288 // Get the first error message to display as the main message.
289 $first_error = reset( $validated_form_data );
290
291 wp_send_json_error(
292 [
293 'message' => $first_error ?? __( 'Please check the form for errors.', 'sureforms' ),
294 'field_errors' => $validated_form_data,
295 ]
296 );
297 }
298
299 $security_type = Helper::get_meta_value( Helper::get_integer_value( $current_form_id ), '_srfm_captcha_security_type' );
300 $selected_captcha_type = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_form_recaptcha', true ) ? Helper::get_string_value( get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_form_recaptcha', true ) ) : '';
301
302 if ( 'none' !== $security_type ) {
303 $global_setting_options = get_option( 'srfm_security_settings_options' );
304 } else {
305 $global_setting_options = [];
306 }
307
308 if ( 'g-recaptcha' === $security_type ) {
309 switch ( $selected_captcha_type ) {
310 case 'v2-checkbox':
311 $key = 'srfm_v2_checkbox_secret_key';
312 break;
313 case 'v2-invisible':
314 $key = 'srfm_v2_invisible_secret_key';
315 break;
316 case 'v3-reCAPTCHA':
317 $key = 'srfm_v3_secret_key';
318 break;
319 default:
320 $key = '';
321 break;
322 }
323
324 $google_captcha_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options[ $key ] ) ? $global_setting_options[ $key ] : '';
325 }
326
327 if ( 'cf-turnstile' === $security_type ) {
328 // Turnstile validation.
329 $srfm_cf_turnstile_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_cf_turnstile_secret_key'] ) ? Helper::get_string_value( $global_setting_options['srfm_cf_turnstile_secret_key'] ) : '';
330 $cf_response = ! empty( $form_data['cf-turnstile-response'] ) && is_string( $form_data['cf-turnstile-response'] ) ? $form_data['cf-turnstile-response'] : '';
331
332 // if gdpr is enabled then set remote ip to empty.
333 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
334 $gdpr = false;
335
336 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
337 $gdpr = ! empty( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : false;
338 }
339
340 // check if ip logging is disabled in global settings then set remote ip to empty.
341 $gb_general_settinionsgs_opt = get_option( 'srfm_general_settings_options' );
342 $srfm_ip_log = is_array( $gb_general_settinionsgs_opt ) && isset( $gb_general_settinionsgs_opt['srfm_ip_log'] ) ? $gb_general_settinionsgs_opt['srfm_ip_log'] : '';
343
344 $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
345
346 $turnstile_validation_result = self::validate_turnstile_token( $srfm_cf_turnstile_secret_key, $cf_response, $remote_ip );
347
348 // If the cloudflare validation fails, return an error.
349 if ( is_array( $turnstile_validation_result ) && isset( $turnstile_validation_result['success'] ) && false === $turnstile_validation_result['success'] ) {
350 $this->recaptcha_error_response( 'cf-turnstile', $turnstile_validation_result );
351 }
352 }
353
354 if ( 'hcaptcha' === $security_type ) {
355 $srfm_hcaptcha_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_hcaptcha_secret_key'] ) ? Helper::get_string_value( $global_setting_options['srfm_hcaptcha_secret_key'] ) : '';
356 $hcaptcha_response = ! empty( $form_data['h-captcha-response'] ) && is_string( $form_data['h-captcha-response'] ) ? $form_data['h-captcha-response'] : '';
357
358 // if gdpr is enabled then set remote ip to empty.
359 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
360 $gdpr = false;
361
362 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
363 $gdpr = ! empty( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : false;
364 }
365
366 // check if ip logging is disabled in global settings then set remote ip to empty.
367 $gb_general_settings_options = get_option( 'srfm_general_settings_options' );
368 $srfm_ip_log = is_array( $gb_general_settings_options ) && isset( $gb_general_settings_options['srfm_ip_log'] ) ? $gb_general_settings_options['srfm_ip_log'] : '';
369
370 $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
371 $hcaptcha_validation_result = self::validate_hcaptcha_token( $srfm_hcaptcha_secret_key, $hcaptcha_response, $remote_ip );
372
373 // If the hcaptcha validation fails, return an error.
374 if ( is_array( $hcaptcha_validation_result ) && isset( $hcaptcha_validation_result['success'] ) && false === $hcaptcha_validation_result['success'] ) {
375 $this->recaptcha_error_response( 'hcaptcha', $hcaptcha_validation_result );
376 }
377 }
378
379 if ( isset( $form_data['srfm-honeypot-field'] ) && empty( $form_data['srfm-honeypot-field'] ) ) {
380 if ( ! empty( $google_captcha_secret_key ) ) {
381 if ( ! empty( $form_data['form-id'] ) ) {
382 $secret_key = $google_captcha_secret_key;
383 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
384 $captcha_response = $form_data['g-recaptcha-response'];
385 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
386
387 $response = wp_remote_get( $url );
388
389 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
390 $json_string = wp_remote_retrieve_body( $response );
391 $data = (array) json_decode( $json_string, true );
392 } else {
393 $data = [];
394 }
395 $sureforms_captcha_data = $data;
396
397 } else {
398 wp_send_json_error(
399 [
400 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
401 ]
402 );
403 }
404 if ( isset( $sureforms_captcha_data['success'] ) && true === $sureforms_captcha_data['success'] ) {
405 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
406 }
407
408 $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
409 }
410
411 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
412 }
413
414 if ( ! isset( $form_data['srfm-honeypot-field'] ) ) {
415 // If honeypot is enabled globally, the missing field means a bot stripped it.
416 $srfm_security_options = get_option( 'srfm_security_settings_options' );
417 if ( is_array( $srfm_security_options ) && ! empty( $srfm_security_options['srfm_honeypot'] ) ) {
418 wp_send_json_error(
419 [
420 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ),
421 ]
422 );
423 }
424
425 if ( ! empty( $google_captcha_secret_key ) ) {
426 if ( ! empty( $form_data['form-id'] ) ) {
427 $secret_key = $google_captcha_secret_key;
428 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
429 $captcha_response = $form_data['g-recaptcha-response'];
430 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
431
432 $response = wp_remote_get( $url );
433
434 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
435 $json_string = wp_remote_retrieve_body( $response );
436 $data = (array) json_decode( $json_string, true );
437 } else {
438 $data = [];
439 }
440 $sureforms_captcha_data = $data;
441
442 } else {
443 wp_send_json_error(
444 [
445 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
446 ]
447 );
448 }
449 if ( true === $sureforms_captcha_data['success'] ) {
450 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
451 }
452
453 $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
454 }
455
456 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
457 }
458
459 wp_send_json_error(
460 [
461 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ),
462 ]
463 );
464 }
465
466 /**
467 * Send Email and Create Entry.
468 *
469 * @param array<string> $form_data Request object or array containing form data.
470 * @since 0.0.1
471 * @return array<mixed> Array containing the response data.
472 */
473 public function handle_form_entry( $form_data ) {
474 // Filter the form data.
475 $form_data = apply_filters( 'srfm_form_submit_data', $form_data );
476 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
477 wp_send_json_error(
478 [
479 'message' => __( 'Form data was not found.', 'sureforms' ),
480 'position' => 'header',
481 ]
482 );
483 } elseif ( isset( $form_data['error'] ) ) {
484 wp_send_json_error(
485 [
486 'message' => is_string( $form_data['error'] ) ? $form_data['error'] : __( 'Form data is not found.', 'sureforms' ),
487 'position' => 'header',
488 ]
489 );
490 }
491
492 $id = sanitize_text_field( $form_data['form-id'] );
493
494 // Get the compliance settings.
495 $compliance = get_post_meta( Helper::get_integer_value( $id ), '_srfm_compliance', true );
496 $gdpr = '';
497 $do_not_store_entries = '';
498
499 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
500 $gdpr = $compliance[0]['gdpr'] ?? '';
501 $do_not_store_entries = $compliance[0]['do_not_store_entries'] ?? '';
502 }
503
504 // Check if the form data contains 'srfm_addresses' and is not empty.
505 if ( ! empty( $form_data['srfm_addresses'] ) ) {
506 // Assign the addresses to the class property for further processing.
507 $this->addresses = $form_data['srfm_addresses'];
508 // Remove the address data from the form data to avoid redundancy.
509 unset( $form_data['srfm_addresses'] );
510 }
511
512 $form_data = apply_filters( 'srfm_before_fields_processing', $form_data );
513
514 $submission_data = $this->process_form_fields( $form_data );
515
516 $modified_message = $this->prepare_submission_data( $submission_data );
517
518 $form_before_submission_data = [
519 'form_id' => $id ? intval( $id ) : '',
520 'data' => $modified_message,
521 ];
522
523 /**
524 * Fires before submission process starts.
525 */
526 do_action( 'srfm_before_submission', $form_before_submission_data );
527
528 $name = sanitize_text_field( get_the_title( intval( $id ) ) );
529 $emails = [];
530
531 // Check if GDPR is enabled and do not store entries is enabled.
532 // If so, send email and do not store entries.
533 if ( $gdpr && $do_not_store_entries ) {
534 // Send email before early return. No entry is created in this path so {entry_id} will be empty — that is expected.
535 $send_email = $this->send_email( $id, $submission_data, $form_data );
536 if ( $send_email ) {
537 $emails = $send_email['emails'];
538 }
539
540 $form_submit_response = [
541 'success' => true,
542 'form_id' => $id ? intval( $id ) : '',
543 'to_emails' => $emails,
544 'form_name' => $name ? esc_attr( $name ) : '',
545 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
546 'data' => $modified_message,
547 ];
548
549 do_action( 'srfm_form_submit', $form_submit_response );
550
551 /**
552 * Hook for enabling background processes.
553 *
554 * @param array $form_data form data related to submission.
555 */
556 $form_data['form_id'] = $id ? intval( $id ) : '';
557 do_action( 'srfm_after_submission_process', $form_data );
558
559 return [
560 'success' => true,
561 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
562 'data' => [
563 'name' => $name,
564 'after_submit' => false,
565 ],
566 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ),
567 ];
568
569 }
570
571 $global_setting_options = get_option( 'srfm_general_settings_options' );
572
573 // If GDPR is enabled, do not store IP, browser, device, and submission URL.
574 // If not, store all of them.
575 $user_ip = '';
576 $browser_name = '';
577 $device_name = '';
578 $submission_url = '';
579 if ( ! $gdpr ) {
580 $srfm_ip_log = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_ip_log'] ) ? $global_setting_options['srfm_ip_log'] : '';
581
582 $user_ip = $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
583 $browser = new Browser();
584 $browser_name = sanitize_text_field( $browser->getBrowser() );
585 $device_name = sanitize_text_field( $browser->getPlatform() );
586
587 // Capture submission page URL server-side from the Referer header. The
588 // stored value is rebuilt from parsed components so a non-browser client
589 // cannot inject attacker-controlled bits a real browser would never send
590 // (userinfo, fragment) or mismatch the legitimate origin's port. Anything
591 // not same-origin http(s) is stored as empty.
592 $referer = isset( $_SERVER['HTTP_REFERER'] )
593 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) )
594 : '';
595 if ( '' !== $referer && strlen( $referer ) <= 2048 ) {
596 $parts = wp_parse_url( $referer );
597 $home_parts = wp_parse_url( home_url() );
598 if (
599 is_array( $parts )
600 && is_array( $home_parts )
601 && isset( $parts['scheme'], $parts['host'], $home_parts['host'] )
602 && in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true )
603 && 0 === strcasecmp( (string) $parts['host'], (string) $home_parts['host'] )
604 && ( $parts['port'] ?? null ) === ( $home_parts['port'] ?? null )
605 ) {
606 $clean = $parts['scheme'] . '://' . $parts['host']
607 . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' )
608 . ( $parts['path'] ?? '' )
609 . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
610 $submission_url = esc_url_raw( $clean, [ 'http', 'https' ] );
611 }
612 }
613 }
614
615 $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) );
616 $pattern = '/"label":"(.*?)"/';
617 preg_match_all( $pattern, $form_markup, $matches );
618 $submission_info = [
619 'user_ip' => $user_ip,
620 'browser_name' => $browser_name,
621 'device_name' => $device_name,
622 'submission_url' => $submission_url,
623 ];
624 $entries_data = [
625 'form_id' => $id,
626 'form_data' => $submission_data,
627 'submission_info' => $submission_info,
628 'created_at' => current_time( 'mysql' ),
629 ];
630 if ( is_user_logged_in() ) {
631 // If user is logged in then save their user id.
632 $entries_data['user_id'] = get_current_user_id();
633 }
634
635 $entries_data = apply_filters(
636 'srfm_before_entry_data',
637 $entries_data,
638 [
639 'form_data' => $form_data,
640 'submission_data' => $submission_data,
641 ]
642 );
643
644 $entry_id = Entries::add( $entries_data );
645 if ( $entry_id ) {
646 // Inject entry_id so {entry_id} smart tag resolves in confirmation message, redirect URL, email notifications, and downstream integrations.
647 $form_data['entry_id'] = intval( $entry_id );
648
649 // Send email after entry creation so {entry_id} is available when smart tags are processed.
650 $send_email = $this->send_email( $id, $submission_data, $form_data );
651 if ( $send_email ) {
652 $emails = $send_email['emails'];
653 }
654
655 $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data );
656
657 $response = [
658 'success' => true,
659 'message' => $confirmation_message,
660 'data' => [
661 'name' => $name,
662 'submission_id' => $entry_id,
663 'after_submit' => true,
664 'after_submit_nonce' => wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ),
665 ],
666 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ),
667 ];
668
669 $form_submit_response = apply_filters(
670 'srfm_form_submit_response',
671 [
672 'success' => true,
673 'form_id' => $id ? intval( $id ) : '',
674 'entry_id' => intval( $entry_id ),
675 'to_emails' => $emails,
676 'form_name' => $name ? esc_attr( $name ) : '',
677 'message' => $confirmation_message,
678 'data' => $modified_message,
679 ]
680 );
681
682 do_action( 'srfm_form_submit', $form_submit_response );
683 } else {
684 $response = [
685 'success' => false,
686 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ),
687 ];
688 }
689
690 /**
691 * Filter the form submission response.
692 *
693 * @param array<mixed> $response The response data.
694 * @param array<string> $form_data The original form data.
695 * @param array<mixed> $submission_data The processed submission data.
696 * @since 2.4.0
697 */
698 return apply_filters( 'srfm_form_submission_response', $response, $form_data, $submission_data );
699 }
700
701 /**
702 * Prepare submission data.
703 *
704 * @param array<mixed> $submission_data Submission data.
705 * @since 0.0.7
706 * @return array<mixed> Modified submission data.
707 */
708 public function prepare_submission_data( $submission_data ) {
709 $modified_message = [];
710 foreach ( $submission_data as $key => $value ) {
711 $parts = explode( '-lbl-', $key );
712 $label = '';
713
714 /**
715 * Filters submission data for field processing.
716 *
717 * This filter allows customization of how individual fields are processed
718 * during submission data preparation. Plugins can modify field values,
719 * labels, or exclude specific fields from the final submission data.
720 *
721 * @since 1.11.0
722 *
723 * @param array $field_data {
724 * Field data for processing.
725 *
726 * @type array $block_parts The field key split by '-lbl-' delimiter.
727 * @type string $field_key The original field key from submission data.
728 * @type mixed $field_value The field value from submission data.
729 * }
730 */
731 $should_add_field_row = apply_filters(
732 'srfm_prepare_submission_data',
733 [
734 'block_parts' => $parts,
735 'field_key' => $key,
736 'field_value' => $value,
737 ]
738 );
739
740 // If we get the label and value from the filter, then use it.
741 if ( ! empty( $should_add_field_row['label'] ) && ! empty( $should_add_field_row['value'] ) ) {
742 $modified_message[ $should_add_field_row['label'] ] = $should_add_field_row['value'];
743 continue;
744 }
745
746 if ( ! empty( $parts[1] ) ) {
747 $tokens = explode( '-', $parts[1] );
748 if ( count( $tokens ) > 1 ) {
749 $label = implode( '-', array_slice( $tokens, 1 ) );
750 }
751
752 $fields = explode( '-', $parts[0] );
753
754 // Since the upload field returns an array of file URLs, we need to implode them with a comma.
755 if ( 'upload' === $fields[1] && ! empty( $value ) && is_array( $value ) ) {
756 $modified_message[ $label ] = implode( ', ', array_map( 'rawurldecode', $value ) );
757 } else {
758 $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) );
759 }
760 }
761 }
762
763 // If the address is not empty, add it to the submission data.
764 // We are providing this for third-party integrations like Ottokit.
765 // They can use compact addresses such as permanent address, temporary address, etc.
766 // The address will be structured as field 1, field 2, and so on.
767 if ( ! empty( $this->addresses ) ) {
768 // Address will be JSON stringified, so decode it.
769 $address = json_decode( wp_unslash( $this->addresses ), true );
770 if ( ! empty( $address ) && is_array( $address ) ) {
771 $modified_message = array_merge( $modified_message, $address );
772 }
773 }
774
775 return apply_filters( 'srfm_update_prepared_submission_data', $modified_message );
776 }
777
778 /**
779 * Parse an email notification template and generate the necessary components for sending an email.
780 *
781 * @param array<mixed> $submission_data An associative array containing submission data to be used in the email template.
782 * @param array<string,string> $item An associative array containing email settings, such as 'email_to', 'subject', 'email_body', and optional headers like 'email_reply_to', 'email_cc', and 'email_bcc'.
783 * @param array<string> $form_data Request object or array containing form data.
784 * @since 1.3.0
785 * @return array<string,string> An associative array containing 'to', 'subject', 'message', and 'headers' for the email.
786 */
787 public static function parse_email_notification_template( $submission_data, $item, $form_data = [] ) {
788 $smart_tags = Smart_Tags::get_instance();
789
790 $to = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_to'], $submission_data ) );
791 $subject = Helper::get_string_value( $smart_tags->process_smart_tags( $item['subject'], $submission_data, $form_data ) );
792 $email_body = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_body'], $submission_data, $form_data ) );
793 $is_raw_format = isset( $item['is_raw_format'] ) && true === $item['is_raw_format'];
794
795 /**
796 * Sanitize the email body after smart tag substitution to prevent XSS.
797 *
798 * After process_smart_tags() resolves {form:slug} placeholders, the body may contain
799 * raw user-submitted values that must not render as executable HTML in email clients.
800 * wp_kses_post() strips dangerous markup (script, on* handlers, javascript: URIs)
801 * while preserving all legitimate email formatting (tables, links, bold, etc.).
802 *
803 * Note: {all_data} is not a recognised smart tag and remains a literal placeholder
804 * at this point; it is substituted later by process_all_data_tag() which applies
805 * its own per-field escaping, so this call does not interfere with that path.
806 *
807 * @since 2.5.2
808 */
809 $email_body = wp_kses_post( $email_body );
810
811 $email_template = new Email_Template();
812 $message = $is_raw_format
813 ? $email_template->render_raw( $submission_data, $email_body )
814 : $email_template->render( $submission_data, $email_body );
815 $headers = 'X-Mailer: PHP/' . phpversion() . "\r\n";
816 $headers .= "Content-Type: text/html; charset=utf-8\r\n";
817
818 // Add the From: to the headers.
819 $headers .= self::add_from_data_in_header( $submission_data, $item, $smart_tags );
820
821 // Handle Reply-To with proper sanitization.
822 if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) {
823 $headers .= 'Reply-To: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) ) ) . "\r\n";
824 }
825
826 // Handle CC with proper sanitization.
827 if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) {
828 $headers .= 'Cc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) ) ) . "\r\n";
829 }
830
831 // Handle BCC with proper sanitization.
832 if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) {
833 $headers .= 'Bcc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) ) ) . "\r\n";
834 }
835
836 return compact( 'to', 'subject', 'message', 'headers' );
837 }
838
839 /**
840 * Send Email.
841 *
842 * @param string $id Form ID.
843 * @param array<mixed> $submission_data Submission data.
844 * @param array<string> $form_data Request object or array containing form data.
845 * @since 0.0.1
846 * @return array<mixed> Array containing the response data.
847 */
848 public static function send_email( $id, $submission_data, $form_data = [] ) {
849 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
850 $is_mail_sent = false;
851 $emails = [];
852
853 // Filter to determine whether the email notification should be sent.
854 $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data );
855
856 if ( is_iterable( $email_notification ) ) {
857 $entries_db_instance = Entries::get_instance();
858 $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) );
859
860 foreach ( $email_notification as $notification ) {
861 foreach ( $notification as $item ) {
862 if ( true === $item['status'] ) {
863
864 $parsed = self::parse_email_notification_template( $submission_data, $item, $form_data );
865
866 // Allow filtering of the email data before it is sent.
867 $parsed = apply_filters( 'srfm_email_notification', $parsed, $submission_data, $item, $form_data );
868
869 // Trigger an action before sending the email, allowing additional processing or logging.
870 do_action( 'srfm_before_email_send', $parsed, $submission_data, $item, $form_data );
871
872 $notification_id = isset( $item['id'] ) ? intval( $item['id'] ) : 0;
873
874 /**
875 * Filter to determine whether the email should be sent.
876 *
877 * @since 1.10.1
878 */
879 $should_send_email = apply_filters(
880 'srfm_should_send_email',
881 true,
882 $notification_id,
883 $id,
884 $form_data,
885 );
886
887 if ( ! wp_validate_boolean( $should_send_email ) ) {
888 continue;
889 }
890
891 /**
892 * Temporary override the content type for wp_mail.
893 * This helps us from breaking of content type from other plugins.
894 *
895 * @since 1.2.2
896 */
897 add_filter(
898 'wp_mail_content_type',
899 static function() {
900 return 'text/html'; // We need "text/html" content type to render our emails.
901 },
902 99
903 );
904
905 /**
906 * Start sending email.
907 * Wrapping it in the buffer because when some plugin such as zoho mail, overrides the wp_mail
908 * function and any exception is thrown ( Or printed ) from that plugin side, it affects the JSON response.
909 * So, to make sure such exceptions doesn't affect our JSON response, we are wrapping it inside buffer.
910 *
911 * Try-Catch does not work because the notice or errors might be echoed by other plugins rather than thrown as an exception.
912 *
913 * @since 1.2.2
914 */
915 $sent = false;
916 ob_start();
917 $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
918 if ( ! $sent ) {
919 // Fallback to default PHP mail if for some reasons wp_mail fails.
920 $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
921 }
922 $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
923
924 if ( is_int( $log_key ) ) {
925 if ( true === $sent ) {
926 $entries_db_instance->update_log(
927 $log_key,
928 null,
929 [
930 /* translators: Here, %s is the comma separated emails list. */
931 sprintf( __( 'Email notification recipient: %s', 'sureforms' ), esc_html( $parsed['to'] ) ),
932 ]
933 );
934 } else {
935 $reason = ! empty( $email_report )
936 ? esc_html( $email_report )
937 : ( ! Helper::is_any_smtp_plugin_active()
938 ? esc_html__( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' )
939 : esc_html__( 'Email sending failed for an unknown reason.', 'sureforms' )
940 );
941
942 $entries_db_instance->update_log(
943 $log_key,
944 null,
945 [
946 sprintf(
947 /* translators: Here, %1$s is the comma separated emails list and %2$s is error report ( if any ). */
948 __(
949 'Email server was unable to send the email notification. Recipient: %1$s. Reason: %2$s',
950 'sureforms'
951 ),
952 esc_html( $parsed['to'] ),
953 $reason
954 ),
955 ]
956 );
957
958 }
959 }
960
961 // Trigger an action after the email is sent, allowing additional processing or logging.
962 do_action(
963 'srfm_after_email_send',
964 $parsed,
965 $submission_data,
966 $item,
967 $form_data
968 );
969
970 $is_mail_sent = $sent;
971 $emails[] = $parsed['to'];
972 }
973 }
974 }
975
976 if ( empty( $emails ) ) {
977 $entries_db_instance->reset_logs();
978 $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) );
979 }
980 }
981
982 return [
983 'success' => $is_mail_sent,
984 'emails' => $emails,
985 ];
986 }
987
988 /**
989 * Validate unique field values for a specific form via AJAX.
990 *
991 * Checks submitted field values against existing entries to determine
992 * if duplicates exist. Rate-limited to prevent data enumeration.
993 *
994 * @since 0.0.1
995 * @since 2.7.0 Added rate limiting, form validation, and optimized query.
996 * @return void
997 */
998 public function field_unique_validation() {
999 $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
1000 $form_id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1001
1002 if ( ! Submit_Token::verify( $token, $form_id ) ) {
1003 wp_send_json_error( [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ] );
1004 }
1005
1006 if ( ! $form_id ) {
1007 wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] );
1008 }
1009
1010 // Validate the form exists and is published to prevent cross-form probing.
1011 if ( 'publish' !== get_post_status( $form_id ) || 'sureforms_form' !== get_post_type( $form_id ) ) {
1012 wp_send_json_error( [ 'error' => __( 'Invalid form.', 'sureforms' ) ] );
1013 }
1014
1015 // Rate limit: 10 requests per minute per IP per form.
1016 if ( $this->is_unique_validation_rate_limited( $form_id ) ) {
1017 wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 );
1018 }
1019
1020 // Extract and validate field values from POST data.
1021 $skip_keys = [ 'action', 'token', 'id' ];
1022 $duplicates = [];
1023
1024 foreach ( $_POST as $raw_key => $raw_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1025 if ( in_array( $raw_key, $skip_keys, true ) ) {
1026 continue;
1027 }
1028
1029 $field_key = str_replace( '_', ' ', sanitize_text_field( $raw_key ) );
1030 $value = sanitize_text_field( wp_unslash( $raw_value ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1031
1032 // Only process SureForms field keys (they contain -lbl- in the name).
1033 if ( false === strpos( $field_key, '-lbl-' ) ) {
1034 continue;
1035 }
1036
1037 if ( '' === $value ) {
1038 continue;
1039 }
1040
1041 // Single optimized query per field instead of loading all entries.
1042 if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) {
1043 $duplicates[] = [ $field_key => 'not unique' ];
1044 }
1045 }
1046
1047 wp_send_json( [ 'data' => $duplicates ] );
1048 }
1049
1050 /**
1051 * Function to save allowed block data.
1052 *
1053 * @since 0.0.1
1054 * @return void
1055 */
1056 public function srfm_global_update_allowed_block() {
1057 if ( ! Helper::current_user_can() ) {
1058 wp_send_json_error();
1059 }
1060
1061 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1062 wp_send_json_error();
1063 }
1064
1065 if ( ! empty( $_POST['defaultAllowedQuickSidebarBlocks'] ) ) {
1066 $srfm_default_allowed_quick_sidebar_blocks = json_decode( sanitize_text_field( wp_unslash( $_POST['defaultAllowedQuickSidebarBlocks'] ) ), true );
1067 Helper::update_admin_settings_option( 'srfm_quick_sidebar_allowed_blocks', $srfm_default_allowed_quick_sidebar_blocks );
1068 wp_send_json_success();
1069 }
1070 wp_send_json_error();
1071 }
1072
1073 /**
1074 * Function to save enable/disable data.
1075 *
1076 * @since 0.0.1
1077 * @return void
1078 */
1079 public function srfm_global_sidebar_enabled() {
1080 if ( ! Helper::current_user_can() ) {
1081 wp_send_json_error();
1082 }
1083
1084 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1085 wp_send_json_error();
1086 }
1087
1088 if ( ! empty( $_POST['enableQuickActionSidebar'] ) ) {
1089 $srfm_enable_quick_action_sidebar = ( 'enabled' === $_POST['enableQuickActionSidebar'] ? 'enabled' : 'disabled' );
1090 Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar );
1091 wp_send_json_success();
1092 }
1093 wp_send_json_error();
1094 }
1095
1096 /**
1097 * Send error response for reCAPTCHA validation failure.
1098 *
1099 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1100 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1101 * @since 1.7.0
1102 * @return void
1103 */
1104 public function recaptcha_error_response( $type, $api_response ) {
1105 $error_message = $this->recaptcha_error_message( $type, $api_response );
1106 $response = array_merge(
1107 [
1108 'api_response' => $api_response,
1109 ],
1110 $error_message
1111 );
1112
1113 wp_send_json_error( $response );
1114 }
1115
1116 /**
1117 * Get the error message for a CAPTCHA validation failure based on the service type and API response.
1118 *
1119 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1120 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1121 * @since 1.7.0
1122 * @return array<string,string> An associative array containing the error message and a detailed message.
1123 */
1124 public function recaptcha_error_message( $type, $api_response ) {
1125
1126 if ( empty( $api_response['error-codes'] ) || ! is_array( $api_response['error-codes'] ) ) {
1127 return [
1128 'detail_message' => __( 'Captcha validation failed. No error code provided.', 'sureforms' ),
1129 'message' => __( 'Captcha validation failed.', 'sureforms' ),
1130 ];
1131 }
1132
1133 /**
1134 * Note: The error codes are not translated because these messages are intended for debugging purposes.
1135 * Translating them would make debugging difficult. These error messages are primarily for developers or administrators.
1136 * A generic message will be displayed to the user, while detailed error information will be logged or shown in the console.
1137 */
1138
1139 // Google reCAPTCHA error codes.
1140 // Reference: (https://developers.google.com/recaptcha/docs/verify#error-code-reference).
1141 $google_recaptcha_error = [
1142 'missing-input-secret' => 'The secret parameter is missing.',
1143 'invalid-input-secret' => 'The secret parameter is invalid or malformed.',
1144 'missing-input-response' => 'The response parameter is missing.',
1145 'invalid-input-response' => 'The response parameter is invalid or malformed.',
1146 'bad-request' => 'The request is invalid or malformed.',
1147 'timeout-or-duplicate' => 'The response is no longer valid: either is too old or has been used previously.',
1148 ];
1149
1150 // hCaptcha error codes.
1151 // Reference: (https://docs.hcaptcha.com/#siteverify-error-codes).
1152 $hcaptcha_errors = [
1153 'missing-input-secret' => 'Your secret key is missing.',
1154 'invalid-input-secret' => 'Your secret key is invalid or malformed.',
1155 'missing-input-response' => 'The response parameter (verification token) is missing.',
1156 'invalid-input-response' => 'The response parameter (verification token) is invalid or malformed.',
1157 'expired-input-response' => 'The response parameter (verification token) is expired. (120s default)',
1158 'already-seen-response' => 'The response parameter (verification token) was already verified once.',
1159 'bad-request' => 'The request is invalid or malformed.',
1160 'missing-remoteip' => 'The remoteip parameter is missing.',
1161 'invalid-remoteip' => 'The remoteip parameter is not a valid IP address or blinded value.',
1162 'not-using-dummy-passcode' => 'You have used a testing sitekey but have not used its matching secret.',
1163 'sitekey-secret-mismatch' => 'The sitekey is not registered with the provided secret.',
1164 ];
1165
1166 // Cloudflare Turnstile error codes.
1167 // Reference: (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).
1168 $cf_turnstile_errors = [
1169 'missing-input-secret' => 'The secret parameter was not passed.',
1170 'invalid-input-secret' => 'The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.',
1171 'missing-input-response' => 'The response parameter (token) was not passed.',
1172 'invalid-input-response' => 'The response parameter (token) is invalid or has expired. Most of the time, this means a fake token has been used. If the error persists, contact customer support.',
1173 'bad-request' => 'The request was rejected because it was malformed.',
1174 'timeout-or-duplicate' => 'The response parameter (token) has already been validated before. This means that the token was issued five minutes ago and is no longer valid, or it was already redeemed.',
1175 'internal-error' => 'An internal error happened while validating the response. The request can be retried.',
1176 ];
1177
1178 $error_code = $api_response['error-codes'][0] ?? 'no-error-code';
1179
1180 $captcha_title = '';
1181 $captcha_message = '';
1182 switch ( $type ) {
1183 case 'g-recaptcha':
1184 $captcha_title = __( 'Google reCAPTCHA', 'sureforms' );
1185 $captcha_message = $google_recaptcha_error[ $error_code ];
1186 break;
1187 case 'hcaptcha':
1188 $captcha_title = __( 'hCaptcha', 'sureforms' );
1189 $captcha_message = $hcaptcha_errors[ $error_code ];
1190 break;
1191 case 'cf-turnstile':
1192 $captcha_title = __( 'Cloudflare Turnstile', 'sureforms' );
1193 $captcha_message = $cf_turnstile_errors[ $error_code ];
1194 break;
1195 default:
1196 $captcha_title = __( 'Unknown Captcha', 'sureforms' );
1197 $captcha_message = __( 'Invalid captcha type.', 'sureforms' );
1198 break;
1199 }
1200
1201 $detail_message = sprintf(
1202 '%s: %s <br> Error Code: %s',
1203 $captcha_title,
1204 $captcha_message ?? 'Unknown error occurred.',
1205 $error_code
1206 );
1207
1208 $message = sprintf(
1209 /* translators: %s is the captcha title. */
1210 __( '%s verification failed. Please contact your site administrator.', 'sureforms' ),
1211 $captcha_title
1212 );
1213
1214 return [
1215 'log_message' => $detail_message, // This variable is used for logging purposes, such as displaying detailed error information in the console on the front end.
1216 'message' => $message,
1217 ];
1218 }
1219
1220 /**
1221 * Check if the current request is rate-limited for unique validation.
1222 *
1223 * Uses transients keyed by IP + form ID to throttle requests.
1224 * Allows 10 requests per 60-second window per IP per form.
1225 *
1226 * @param int $form_id The form ID being validated.
1227 * @since 2.7.0
1228 * @return bool True if rate-limited (should block), false if allowed.
1229 */
1230 private function is_unique_validation_rate_limited( $form_id ) {
1231 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
1232
1233 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1234 return true; // Fail closed if IP cannot be determined.
1235 }
1236
1237 $transient_key = 'srfm_uv_' . md5( $ip . '_' . $form_id );
1238 $attempts = get_transient( $transient_key );
1239
1240 if ( false === $attempts ) {
1241 set_transient( $transient_key, 1, MINUTE_IN_SECONDS );
1242 return false;
1243 }
1244
1245 $attempts_count = Helper::get_integer_value( $attempts );
1246
1247 if ( $attempts_count >= 10 ) {
1248 return true;
1249 }
1250
1251 set_transient( $transient_key, $attempts_count + 1, MINUTE_IN_SECONDS );
1252 return false;
1253 }
1254
1255 /**
1256 * Process and sanitize SureForms field data from submitted form data.
1257 *
1258 * @param array<mixed> $form_data Raw form data from submission.
1259 *
1260 * @since 1.11.0
1261 * @return array Processed and sanitized submission data.
1262 */
1263 private function process_form_fields( $form_data ) {
1264 $form_id = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? absint( $form_data['form-id'] ) : 0;
1265
1266 $submission_data = [];
1267
1268 $form_data_keys = array_keys( $form_data );
1269 $form_data_count = count( $form_data );
1270
1271 for ( $i = 0; $i < $form_data_count; $i++ ) {
1272 $key = strval( $form_data_keys[ $i ] );
1273
1274 /**
1275 * This will allow to pass only sureforms fields
1276 * checking -lbl- as thats mandatory for in key of sureforms fields.
1277 */
1278 if ( false === str_contains( $key, '-lbl-' ) ) {
1279 continue;
1280 }
1281
1282 $value = $form_data[ $key ];
1283
1284 $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
1285
1286 $field_block_name = Helper::get_block_name_from_field( $field_name );
1287
1288 /**
1289 * Filters the field value during form submission processing.
1290 *
1291 * This filter allows the Pro plugin to process and modify field values before they are saved.
1292 * The Pro plugin can implement custom sanitization, validation and escaping logic for its
1293 * specialized field types. When this filter is used by Pro, the core plugin will skip its
1294 * default validation.
1295 *
1296 * @since 1.11.0
1297 *
1298 * @param mixed $value The raw field value from form submission.
1299 * @param array $field_data Field information array containing:
1300 * - 'field_name': The field name/key
1301 * - 'field_block_name': The block type identifier
1302 * @return array {
1303 * Processed field value data
1304 *
1305 * @type bool $is_processed Whether the value was processed by Pro plugin
1306 * @type mixed $value The processed and sanitized field value
1307 * }
1308 */
1309 $process_field_value = apply_filters(
1310 'srfm_process_field_value',
1311 $value,
1312 [
1313 'field_name' => $field_name,
1314 'field_block_name' => $field_block_name,
1315 ]
1316 );
1317
1318 if ( is_array( $process_field_value ) && ! empty( $process_field_value['is_processed'] ) && ! empty( $process_field_value['value'] ) ) {
1319 $submission_data[ $field_name ] = $process_field_value['value'];
1320 continue;
1321 }
1322
1323 /**
1324 * Need to remove this refactor array value handling.
1325 *
1326 * The current array-based value handling needs to be replaced with:
1327 * 1. Block-specific value processing based on block type.
1328 * 2. Move premium features to pro version.
1329 * 3. Implement value processing through filters for extensibility.
1330 *
1331 * This will improve code organization and maintainability while properly
1332 * separating free/pro functionality.
1333 */
1334
1335 // If the field is an array, encode the values. This is to add support for multi-upload field.
1336 if ( is_array( $value ) ) {
1337 $submission_data[ $field_name ] =
1338 array_map(
1339 static function ( $val ) {
1340 return rawurlencode( $val );
1341 },
1342 $value
1343 );
1344 } else {
1345 $submission_data[ $field_name ] = is_string( $value ) ? htmlspecialchars( $value ) : $value;
1346 }
1347 }
1348
1349 /**
1350 * Filters the submission data before preparing it for storage.
1351 *
1352 * The second parameter is a context array containing additional metadata
1353 * about the submission. This array is extensible — new keys may be added
1354 * in future versions without changing the filter signature.
1355 *
1356 * @since 2.6.0
1357 *
1358 * @param array<string,mixed> $submission_data Processed form submission data.
1359 * @param array<string,mixed> $context {
1360 * Additional context for the submission.
1361 *
1362 * @type int $form_id The ID of the form being submitted.
1363 * }
1364 */
1365 return apply_filters(
1366 'srfm_before_prepare_submission_data',
1367 $submission_data,
1368 [
1369 'form_id' => $form_id,
1370 ]
1371 );
1372 }
1373
1374 /**
1375 * Add From email and name in the header.
1376 *
1377 * @param array<mixed> $submission_data Submission data.
1378 * @param array<string> $item An associative array containing email settings, such as 'email_to', 'subject', 'email_body', and optional headers like 'email_reply_to', 'email_cc', and 'email_bcc'.
1379 * @param Smart_Tags $smart_tags Smart Tags instance.
1380 * @since 1.6.1
1381 * @return string The formatted "From" email header.
1382 */
1383 private static function add_from_data_in_header( $submission_data, $item, $smart_tags ) {
1384 $from_name = is_array( $item ) && ! empty( $item['from_name'] ) ? sanitize_text_field( Helper::get_string_value( $item['from_name'] ) ) : '{site_title}';
1385 $from_email = is_array( $item ) && ! empty( $item['from_email'] ) ? Helper::get_string_value( $item['from_email'] ) : '{admin_email}';
1386
1387 // Check if the email contains smart tags. If not, validate the email.
1388 $is_valid_email = true;
1389 if ( ! str_contains( $from_email, '{' ) && ! str_contains( $from_email, '}' ) ) {
1390 $is_valid_email = filter_var( $from_email, FILTER_VALIDATE_EMAIL );
1391 }
1392 // if the email is not valid, set it to the admin email.
1393 if ( ! $is_valid_email ) {
1394 $from_email = Helper::get_string_value( get_option( 'admin_email' ) );
1395 }
1396
1397 return 'From: ' . esc_html( Helper::get_string_value( $smart_tags->process_smart_tags( $from_name, $submission_data ) ) ) . ' <' . esc_html( Helper::get_string_value( $smart_tags->process_smart_tags( $from_email, $submission_data ) ) ) . '>' . "\r\n";
1398 }
1399 }
1400