PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.10.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.10.0
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,394 lines 50.6 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 $send_email = $this->send_email( $id, $submission_data, $form_data );
530 $emails = [];
531
532 if ( $send_email ) {
533 $emails = $send_email['emails'];
534 }
535
536 // Check if GDPR is enabled and do not store entries is enabled.
537 // If so, send email and do not store entries.
538 if ( $gdpr && $do_not_store_entries ) {
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, and downstream integrations.
647 $form_data['entry_id'] = intval( $entry_id );
648
649 $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data );
650
651 $response = [
652 'success' => true,
653 'message' => $confirmation_message,
654 'data' => [
655 'name' => $name,
656 'submission_id' => $entry_id,
657 'after_submit' => true,
658 'after_submit_nonce' => wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ),
659 ],
660 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ),
661 ];
662
663 $form_submit_response = apply_filters(
664 'srfm_form_submit_response',
665 [
666 'success' => true,
667 'form_id' => $id ? intval( $id ) : '',
668 'entry_id' => intval( $entry_id ),
669 'to_emails' => $emails,
670 'form_name' => $name ? esc_attr( $name ) : '',
671 'message' => $confirmation_message,
672 'data' => $modified_message,
673 ]
674 );
675
676 do_action( 'srfm_form_submit', $form_submit_response );
677 } else {
678 $response = [
679 'success' => false,
680 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ),
681 ];
682 }
683
684 /**
685 * Filter the form submission response.
686 *
687 * @param array<mixed> $response The response data.
688 * @param array<string> $form_data The original form data.
689 * @param array<mixed> $submission_data The processed submission data.
690 * @since 2.4.0
691 */
692 return apply_filters( 'srfm_form_submission_response', $response, $form_data, $submission_data );
693 }
694
695 /**
696 * Prepare submission data.
697 *
698 * @param array<mixed> $submission_data Submission data.
699 * @since 0.0.7
700 * @return array<mixed> Modified submission data.
701 */
702 public function prepare_submission_data( $submission_data ) {
703 $modified_message = [];
704 foreach ( $submission_data as $key => $value ) {
705 $parts = explode( '-lbl-', $key );
706 $label = '';
707
708 /**
709 * Filters submission data for field processing.
710 *
711 * This filter allows customization of how individual fields are processed
712 * during submission data preparation. Plugins can modify field values,
713 * labels, or exclude specific fields from the final submission data.
714 *
715 * @since 1.11.0
716 *
717 * @param array $field_data {
718 * Field data for processing.
719 *
720 * @type array $block_parts The field key split by '-lbl-' delimiter.
721 * @type string $field_key The original field key from submission data.
722 * @type mixed $field_value The field value from submission data.
723 * }
724 */
725 $should_add_field_row = apply_filters(
726 'srfm_prepare_submission_data',
727 [
728 'block_parts' => $parts,
729 'field_key' => $key,
730 'field_value' => $value,
731 ]
732 );
733
734 // If we get the label and value from the filter, then use it.
735 if ( ! empty( $should_add_field_row['label'] ) && ! empty( $should_add_field_row['value'] ) ) {
736 $modified_message[ $should_add_field_row['label'] ] = $should_add_field_row['value'];
737 continue;
738 }
739
740 if ( ! empty( $parts[1] ) ) {
741 $tokens = explode( '-', $parts[1] );
742 if ( count( $tokens ) > 1 ) {
743 $label = implode( '-', array_slice( $tokens, 1 ) );
744 }
745
746 $fields = explode( '-', $parts[0] );
747
748 // Since the upload field returns an array of file URLs, we need to implode them with a comma.
749 if ( 'upload' === $fields[1] && ! empty( $value ) && is_array( $value ) ) {
750 $modified_message[ $label ] = implode( ', ', array_map( 'rawurldecode', $value ) );
751 } else {
752 $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) );
753 }
754 }
755 }
756
757 // If the address is not empty, add it to the submission data.
758 // We are providing this for third-party integrations like Ottokit.
759 // They can use compact addresses such as permanent address, temporary address, etc.
760 // The address will be structured as field 1, field 2, and so on.
761 if ( ! empty( $this->addresses ) ) {
762 // Address will be JSON stringified, so decode it.
763 $address = json_decode( wp_unslash( $this->addresses ), true );
764 if ( ! empty( $address ) && is_array( $address ) ) {
765 $modified_message = array_merge( $modified_message, $address );
766 }
767 }
768
769 return apply_filters( 'srfm_update_prepared_submission_data', $modified_message );
770 }
771
772 /**
773 * Parse an email notification template and generate the necessary components for sending an email.
774 *
775 * @param array<mixed> $submission_data An associative array containing submission data to be used in the email template.
776 * @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'.
777 * @param array<string> $form_data Request object or array containing form data.
778 * @since 1.3.0
779 * @return array<string,string> An associative array containing 'to', 'subject', 'message', and 'headers' for the email.
780 */
781 public static function parse_email_notification_template( $submission_data, $item, $form_data = [] ) {
782 $smart_tags = Smart_Tags::get_instance();
783
784 $to = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_to'], $submission_data ) );
785 $subject = Helper::get_string_value( $smart_tags->process_smart_tags( $item['subject'], $submission_data, $form_data ) );
786 $email_body = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_body'], $submission_data, $form_data ) );
787 $is_raw_format = isset( $item['is_raw_format'] ) && true === $item['is_raw_format'];
788
789 /**
790 * Sanitize the email body after smart tag substitution to prevent XSS.
791 *
792 * After process_smart_tags() resolves {form:slug} placeholders, the body may contain
793 * raw user-submitted values that must not render as executable HTML in email clients.
794 * wp_kses_post() strips dangerous markup (script, on* handlers, javascript: URIs)
795 * while preserving all legitimate email formatting (tables, links, bold, etc.).
796 *
797 * Note: {all_data} is not a recognised smart tag and remains a literal placeholder
798 * at this point; it is substituted later by process_all_data_tag() which applies
799 * its own per-field escaping, so this call does not interfere with that path.
800 *
801 * @since 2.5.2
802 */
803 $email_body = wp_kses_post( $email_body );
804
805 $email_template = new Email_Template();
806 $message = $is_raw_format
807 ? $email_template->render_raw( $submission_data, $email_body )
808 : $email_template->render( $submission_data, $email_body );
809 $headers = 'X-Mailer: PHP/' . phpversion() . "\r\n";
810 $headers .= "Content-Type: text/html; charset=utf-8\r\n";
811
812 // Add the From: to the headers.
813 $headers .= self::add_from_data_in_header( $submission_data, $item, $smart_tags );
814
815 // Handle Reply-To with proper sanitization.
816 if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) {
817 $headers .= 'Reply-To: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) ) ) . "\r\n";
818 }
819
820 // Handle CC with proper sanitization.
821 if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) {
822 $headers .= 'Cc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) ) ) . "\r\n";
823 }
824
825 // Handle BCC with proper sanitization.
826 if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) {
827 $headers .= 'Bcc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) ) ) . "\r\n";
828 }
829
830 return compact( 'to', 'subject', 'message', 'headers' );
831 }
832
833 /**
834 * Send Email.
835 *
836 * @param string $id Form ID.
837 * @param array<mixed> $submission_data Submission data.
838 * @param array<string> $form_data Request object or array containing form data.
839 * @since 0.0.1
840 * @return array<mixed> Array containing the response data.
841 */
842 public static function send_email( $id, $submission_data, $form_data = [] ) {
843 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
844 $is_mail_sent = false;
845 $emails = [];
846
847 // Filter to determine whether the email notification should be sent.
848 $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data );
849
850 if ( is_iterable( $email_notification ) ) {
851 $entries_db_instance = Entries::get_instance();
852 $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) );
853
854 foreach ( $email_notification as $notification ) {
855 foreach ( $notification as $item ) {
856 if ( true === $item['status'] ) {
857
858 $parsed = self::parse_email_notification_template( $submission_data, $item, $form_data );
859
860 // Allow filtering of the email data before it is sent.
861 $parsed = apply_filters( 'srfm_email_notification', $parsed, $submission_data, $item, $form_data );
862
863 // Trigger an action before sending the email, allowing additional processing or logging.
864 do_action( 'srfm_before_email_send', $parsed, $submission_data, $item, $form_data );
865
866 $notification_id = isset( $item['id'] ) ? intval( $item['id'] ) : 0;
867
868 /**
869 * Filter to determine whether the email should be sent.
870 *
871 * @since 1.10.1
872 */
873 $should_send_email = apply_filters(
874 'srfm_should_send_email',
875 true,
876 $notification_id,
877 $id,
878 $form_data,
879 );
880
881 if ( ! wp_validate_boolean( $should_send_email ) ) {
882 continue;
883 }
884
885 /**
886 * Temporary override the content type for wp_mail.
887 * This helps us from breaking of content type from other plugins.
888 *
889 * @since 1.2.2
890 */
891 add_filter(
892 'wp_mail_content_type',
893 static function() {
894 return 'text/html'; // We need "text/html" content type to render our emails.
895 },
896 99
897 );
898
899 /**
900 * Start sending email.
901 * Wrapping it in the buffer because when some plugin such as zoho mail, overrides the wp_mail
902 * function and any exception is thrown ( Or printed ) from that plugin side, it affects the JSON response.
903 * So, to make sure such exceptions doesn't affect our JSON response, we are wrapping it inside buffer.
904 *
905 * Try-Catch does not work because the notice or errors might be echoed by other plugins rather than thrown as an exception.
906 *
907 * @since 1.2.2
908 */
909 $sent = false;
910 ob_start();
911 $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
912 if ( ! $sent ) {
913 // Fallback to default PHP mail if for some reasons wp_mail fails.
914 $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
915 }
916 $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
917
918 if ( is_int( $log_key ) ) {
919 if ( true === $sent ) {
920 $entries_db_instance->update_log(
921 $log_key,
922 null,
923 [
924 /* translators: Here, %s is the comma separated emails list. */
925 sprintf( __( 'Email notification recipient: %s', 'sureforms' ), esc_html( $parsed['to'] ) ),
926 ]
927 );
928 } else {
929 $reason = ! empty( $email_report )
930 ? esc_html( $email_report )
931 : ( ! Helper::is_any_smtp_plugin_active()
932 ? esc_html__( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' )
933 : esc_html__( 'Email sending failed for an unknown reason.', 'sureforms' )
934 );
935
936 $entries_db_instance->update_log(
937 $log_key,
938 null,
939 [
940 sprintf(
941 /* translators: Here, %1$s is the comma separated emails list and %2$s is error report ( if any ). */
942 __(
943 'Email server was unable to send the email notification. Recipient: %1$s. Reason: %2$s',
944 'sureforms'
945 ),
946 esc_html( $parsed['to'] ),
947 $reason
948 ),
949 ]
950 );
951
952 }
953 }
954
955 // Trigger an action after the email is sent, allowing additional processing or logging.
956 do_action(
957 'srfm_after_email_send',
958 $parsed,
959 $submission_data,
960 $item,
961 $form_data
962 );
963
964 $is_mail_sent = $sent;
965 $emails[] = $parsed['to'];
966 }
967 }
968 }
969
970 if ( empty( $emails ) ) {
971 $entries_db_instance->reset_logs();
972 $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) );
973 }
974 }
975
976 return [
977 'success' => $is_mail_sent,
978 'emails' => $emails,
979 ];
980 }
981
982 /**
983 * Validate unique field values for a specific form via AJAX.
984 *
985 * Checks submitted field values against existing entries to determine
986 * if duplicates exist. Rate-limited to prevent data enumeration.
987 *
988 * @since 0.0.1
989 * @since 2.7.0 Added rate limiting, form validation, and optimized query.
990 * @return void
991 */
992 public function field_unique_validation() {
993 $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
994 $form_id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
995
996 if ( ! Submit_Token::verify( $token, $form_id ) ) {
997 wp_send_json_error( [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ] );
998 }
999
1000 if ( ! $form_id ) {
1001 wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] );
1002 }
1003
1004 // Validate the form exists and is published to prevent cross-form probing.
1005 if ( 'publish' !== get_post_status( $form_id ) || 'sureforms_form' !== get_post_type( $form_id ) ) {
1006 wp_send_json_error( [ 'error' => __( 'Invalid form.', 'sureforms' ) ] );
1007 }
1008
1009 // Rate limit: 10 requests per minute per IP per form.
1010 if ( $this->is_unique_validation_rate_limited( $form_id ) ) {
1011 wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 );
1012 }
1013
1014 // Extract and validate field values from POST data.
1015 $skip_keys = [ 'action', 'token', 'id' ];
1016 $duplicates = [];
1017
1018 foreach ( $_POST as $raw_key => $raw_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1019 if ( in_array( $raw_key, $skip_keys, true ) ) {
1020 continue;
1021 }
1022
1023 $field_key = str_replace( '_', ' ', sanitize_text_field( $raw_key ) );
1024 $value = sanitize_text_field( wp_unslash( $raw_value ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1025
1026 // Only process SureForms field keys (they contain -lbl- in the name).
1027 if ( false === strpos( $field_key, '-lbl-' ) ) {
1028 continue;
1029 }
1030
1031 if ( '' === $value ) {
1032 continue;
1033 }
1034
1035 // Single optimized query per field instead of loading all entries.
1036 if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) {
1037 $duplicates[] = [ $field_key => 'not unique' ];
1038 }
1039 }
1040
1041 wp_send_json( [ 'data' => $duplicates ] );
1042 }
1043
1044 /**
1045 * Function to save allowed block data.
1046 *
1047 * @since 0.0.1
1048 * @return void
1049 */
1050 public function srfm_global_update_allowed_block() {
1051 if ( ! Helper::current_user_can() ) {
1052 wp_send_json_error();
1053 }
1054
1055 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1056 wp_send_json_error();
1057 }
1058
1059 if ( ! empty( $_POST['defaultAllowedQuickSidebarBlocks'] ) ) {
1060 $srfm_default_allowed_quick_sidebar_blocks = json_decode( sanitize_text_field( wp_unslash( $_POST['defaultAllowedQuickSidebarBlocks'] ) ), true );
1061 Helper::update_admin_settings_option( 'srfm_quick_sidebar_allowed_blocks', $srfm_default_allowed_quick_sidebar_blocks );
1062 wp_send_json_success();
1063 }
1064 wp_send_json_error();
1065 }
1066
1067 /**
1068 * Function to save enable/disable data.
1069 *
1070 * @since 0.0.1
1071 * @return void
1072 */
1073 public function srfm_global_sidebar_enabled() {
1074 if ( ! Helper::current_user_can() ) {
1075 wp_send_json_error();
1076 }
1077
1078 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1079 wp_send_json_error();
1080 }
1081
1082 if ( ! empty( $_POST['enableQuickActionSidebar'] ) ) {
1083 $srfm_enable_quick_action_sidebar = ( 'enabled' === $_POST['enableQuickActionSidebar'] ? 'enabled' : 'disabled' );
1084 Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar );
1085 wp_send_json_success();
1086 }
1087 wp_send_json_error();
1088 }
1089
1090 /**
1091 * Send error response for reCAPTCHA validation failure.
1092 *
1093 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1094 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1095 * @since 1.7.0
1096 * @return void
1097 */
1098 public function recaptcha_error_response( $type, $api_response ) {
1099 $error_message = $this->recaptcha_error_message( $type, $api_response );
1100 $response = array_merge(
1101 [
1102 'api_response' => $api_response,
1103 ],
1104 $error_message
1105 );
1106
1107 wp_send_json_error( $response );
1108 }
1109
1110 /**
1111 * Get the error message for a CAPTCHA validation failure based on the service type and API response.
1112 *
1113 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1114 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1115 * @since 1.7.0
1116 * @return array<string,string> An associative array containing the error message and a detailed message.
1117 */
1118 public function recaptcha_error_message( $type, $api_response ) {
1119
1120 if ( empty( $api_response['error-codes'] ) || ! is_array( $api_response['error-codes'] ) ) {
1121 return [
1122 'detail_message' => __( 'Captcha validation failed. No error code provided.', 'sureforms' ),
1123 'message' => __( 'Captcha validation failed.', 'sureforms' ),
1124 ];
1125 }
1126
1127 /**
1128 * Note: The error codes are not translated because these messages are intended for debugging purposes.
1129 * Translating them would make debugging difficult. These error messages are primarily for developers or administrators.
1130 * A generic message will be displayed to the user, while detailed error information will be logged or shown in the console.
1131 */
1132
1133 // Google reCAPTCHA error codes.
1134 // Reference: (https://developers.google.com/recaptcha/docs/verify#error-code-reference).
1135 $google_recaptcha_error = [
1136 'missing-input-secret' => 'The secret parameter is missing.',
1137 'invalid-input-secret' => 'The secret parameter is invalid or malformed.',
1138 'missing-input-response' => 'The response parameter is missing.',
1139 'invalid-input-response' => 'The response parameter is invalid or malformed.',
1140 'bad-request' => 'The request is invalid or malformed.',
1141 'timeout-or-duplicate' => 'The response is no longer valid: either is too old or has been used previously.',
1142 ];
1143
1144 // hCaptcha error codes.
1145 // Reference: (https://docs.hcaptcha.com/#siteverify-error-codes).
1146 $hcaptcha_errors = [
1147 'missing-input-secret' => 'Your secret key is missing.',
1148 'invalid-input-secret' => 'Your secret key is invalid or malformed.',
1149 'missing-input-response' => 'The response parameter (verification token) is missing.',
1150 'invalid-input-response' => 'The response parameter (verification token) is invalid or malformed.',
1151 'expired-input-response' => 'The response parameter (verification token) is expired. (120s default)',
1152 'already-seen-response' => 'The response parameter (verification token) was already verified once.',
1153 'bad-request' => 'The request is invalid or malformed.',
1154 'missing-remoteip' => 'The remoteip parameter is missing.',
1155 'invalid-remoteip' => 'The remoteip parameter is not a valid IP address or blinded value.',
1156 'not-using-dummy-passcode' => 'You have used a testing sitekey but have not used its matching secret.',
1157 'sitekey-secret-mismatch' => 'The sitekey is not registered with the provided secret.',
1158 ];
1159
1160 // Cloudflare Turnstile error codes.
1161 // Reference: (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).
1162 $cf_turnstile_errors = [
1163 'missing-input-secret' => 'The secret parameter was not passed.',
1164 'invalid-input-secret' => 'The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.',
1165 'missing-input-response' => 'The response parameter (token) was not passed.',
1166 '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.',
1167 'bad-request' => 'The request was rejected because it was malformed.',
1168 '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.',
1169 'internal-error' => 'An internal error happened while validating the response. The request can be retried.',
1170 ];
1171
1172 $error_code = $api_response['error-codes'][0] ?? 'no-error-code';
1173
1174 $captcha_title = '';
1175 $captcha_message = '';
1176 switch ( $type ) {
1177 case 'g-recaptcha':
1178 $captcha_title = __( 'Google reCAPTCHA', 'sureforms' );
1179 $captcha_message = $google_recaptcha_error[ $error_code ];
1180 break;
1181 case 'hcaptcha':
1182 $captcha_title = __( 'hCaptcha', 'sureforms' );
1183 $captcha_message = $hcaptcha_errors[ $error_code ];
1184 break;
1185 case 'cf-turnstile':
1186 $captcha_title = __( 'Cloudflare Turnstile', 'sureforms' );
1187 $captcha_message = $cf_turnstile_errors[ $error_code ];
1188 break;
1189 default:
1190 $captcha_title = __( 'Unknown Captcha', 'sureforms' );
1191 $captcha_message = __( 'Invalid captcha type.', 'sureforms' );
1192 break;
1193 }
1194
1195 $detail_message = sprintf(
1196 '%s: %s <br> Error Code: %s',
1197 $captcha_title,
1198 $captcha_message ?? 'Unknown error occurred.',
1199 $error_code
1200 );
1201
1202 $message = sprintf(
1203 /* translators: %s is the captcha title. */
1204 __( '%s verification failed. Please contact your site administrator.', 'sureforms' ),
1205 $captcha_title
1206 );
1207
1208 return [
1209 'log_message' => $detail_message, // This variable is used for logging purposes, such as displaying detailed error information in the console on the front end.
1210 'message' => $message,
1211 ];
1212 }
1213
1214 /**
1215 * Check if the current request is rate-limited for unique validation.
1216 *
1217 * Uses transients keyed by IP + form ID to throttle requests.
1218 * Allows 10 requests per 60-second window per IP per form.
1219 *
1220 * @param int $form_id The form ID being validated.
1221 * @since 2.7.0
1222 * @return bool True if rate-limited (should block), false if allowed.
1223 */
1224 private function is_unique_validation_rate_limited( $form_id ) {
1225 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
1226
1227 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1228 return true; // Fail closed if IP cannot be determined.
1229 }
1230
1231 $transient_key = 'srfm_uv_' . md5( $ip . '_' . $form_id );
1232 $attempts = get_transient( $transient_key );
1233
1234 if ( false === $attempts ) {
1235 set_transient( $transient_key, 1, MINUTE_IN_SECONDS );
1236 return false;
1237 }
1238
1239 $attempts_count = Helper::get_integer_value( $attempts );
1240
1241 if ( $attempts_count >= 10 ) {
1242 return true;
1243 }
1244
1245 set_transient( $transient_key, $attempts_count + 1, MINUTE_IN_SECONDS );
1246 return false;
1247 }
1248
1249 /**
1250 * Process and sanitize SureForms field data from submitted form data.
1251 *
1252 * @param array<mixed> $form_data Raw form data from submission.
1253 *
1254 * @since 1.11.0
1255 * @return array Processed and sanitized submission data.
1256 */
1257 private function process_form_fields( $form_data ) {
1258 $form_id = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? absint( $form_data['form-id'] ) : 0;
1259
1260 $submission_data = [];
1261
1262 $form_data_keys = array_keys( $form_data );
1263 $form_data_count = count( $form_data );
1264
1265 for ( $i = 0; $i < $form_data_count; $i++ ) {
1266 $key = strval( $form_data_keys[ $i ] );
1267
1268 /**
1269 * This will allow to pass only sureforms fields
1270 * checking -lbl- as thats mandatory for in key of sureforms fields.
1271 */
1272 if ( false === str_contains( $key, '-lbl-' ) ) {
1273 continue;
1274 }
1275
1276 $value = $form_data[ $key ];
1277
1278 $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
1279
1280 $field_block_name = Helper::get_block_name_from_field( $field_name );
1281
1282 /**
1283 * Filters the field value during form submission processing.
1284 *
1285 * This filter allows the Pro plugin to process and modify field values before they are saved.
1286 * The Pro plugin can implement custom sanitization, validation and escaping logic for its
1287 * specialized field types. When this filter is used by Pro, the core plugin will skip its
1288 * default validation.
1289 *
1290 * @since 1.11.0
1291 *
1292 * @param mixed $value The raw field value from form submission.
1293 * @param array $field_data Field information array containing:
1294 * - 'field_name': The field name/key
1295 * - 'field_block_name': The block type identifier
1296 * @return array {
1297 * Processed field value data
1298 *
1299 * @type bool $is_processed Whether the value was processed by Pro plugin
1300 * @type mixed $value The processed and sanitized field value
1301 * }
1302 */
1303 $process_field_value = apply_filters(
1304 'srfm_process_field_value',
1305 $value,
1306 [
1307 'field_name' => $field_name,
1308 'field_block_name' => $field_block_name,
1309 ]
1310 );
1311
1312 if ( is_array( $process_field_value ) && ! empty( $process_field_value['is_processed'] ) && ! empty( $process_field_value['value'] ) ) {
1313 $submission_data[ $field_name ] = $process_field_value['value'];
1314 continue;
1315 }
1316
1317 /**
1318 * Need to remove this refactor array value handling.
1319 *
1320 * The current array-based value handling needs to be replaced with:
1321 * 1. Block-specific value processing based on block type.
1322 * 2. Move premium features to pro version.
1323 * 3. Implement value processing through filters for extensibility.
1324 *
1325 * This will improve code organization and maintainability while properly
1326 * separating free/pro functionality.
1327 */
1328
1329 // If the field is an array, encode the values. This is to add support for multi-upload field.
1330 if ( is_array( $value ) ) {
1331 $submission_data[ $field_name ] =
1332 array_map(
1333 static function ( $val ) {
1334 return rawurlencode( $val );
1335 },
1336 $value
1337 );
1338 } else {
1339 $submission_data[ $field_name ] = is_string( $value ) ? htmlspecialchars( $value ) : $value;
1340 }
1341 }
1342
1343 /**
1344 * Filters the submission data before preparing it for storage.
1345 *
1346 * The second parameter is a context array containing additional metadata
1347 * about the submission. This array is extensible — new keys may be added
1348 * in future versions without changing the filter signature.
1349 *
1350 * @since 2.6.0
1351 *
1352 * @param array<string,mixed> $submission_data Processed form submission data.
1353 * @param array<string,mixed> $context {
1354 * Additional context for the submission.
1355 *
1356 * @type int $form_id The ID of the form being submitted.
1357 * }
1358 */
1359 return apply_filters(
1360 'srfm_before_prepare_submission_data',
1361 $submission_data,
1362 [
1363 'form_id' => $form_id,
1364 ]
1365 );
1366 }
1367
1368 /**
1369 * Add From email and name in the header.
1370 *
1371 * @param array<mixed> $submission_data Submission data.
1372 * @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'.
1373 * @param Smart_Tags $smart_tags Smart Tags instance.
1374 * @since 1.6.1
1375 * @return string The formatted "From" email header.
1376 */
1377 private static function add_from_data_in_header( $submission_data, $item, $smart_tags ) {
1378 $from_name = is_array( $item ) && ! empty( $item['from_name'] ) ? sanitize_text_field( Helper::get_string_value( $item['from_name'] ) ) : '{site_title}';
1379 $from_email = is_array( $item ) && ! empty( $item['from_email'] ) ? Helper::get_string_value( $item['from_email'] ) : '{admin_email}';
1380
1381 // Check if the email contains smart tags. If not, validate the email.
1382 $is_valid_email = true;
1383 if ( ! str_contains( $from_email, '{' ) && ! str_contains( $from_email, '}' ) ) {
1384 $is_valid_email = filter_var( $from_email, FILTER_VALIDATE_EMAIL );
1385 }
1386 // if the email is not valid, set it to the admin email.
1387 if ( ! $is_valid_email ) {
1388 $from_email = Helper::get_string_value( get_option( 'admin_email' ) );
1389 }
1390
1391 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";
1392 }
1393 }
1394