PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.1
2.12.8 2.12.7 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 All 98 releases
sureforms / inc / form-submit.php

form-submit.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.1, at inc/form-submit.php

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