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

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

1,901 lines 71.1 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 // One submission getting through retires the failure notice. srfm_form_submit
58 // fires only on the success path.
59 add_action( 'srfm_form_submit', [ Client_Logger::class, 'reset_fault_streak' ] );
60
61 /**
62 * Fired when an integration fails to receive a submission.
63 *
64 * Pro's webhooks and native integrations write their outcome to the entry's
65 * own log, which nobody reads until a ticket is already open. Firing this
66 * as well surfaces it on the dashboard.
67 *
68 * @since 2.12.6
69 *
70 * @param int $form_id Form the submission belongs to.
71 * @param string $reason Short description of what failed.
72 */
73 add_action( 'srfm_integration_failed', [ $this, 'record_integration_failure' ], 10, 2 );
74 add_action( 'wp_ajax_validation_ajax_action', [ $this, 'field_unique_validation' ] );
75 add_action( 'wp_ajax_nopriv_validation_ajax_action', [ $this, 'field_unique_validation' ] );
76 // for quick action bar.
77 add_action( 'wp_ajax_srfm_global_update_allowed_block', [ $this, 'srfm_global_update_allowed_block' ] );
78 add_action( 'wp_ajax_srfm_global_sidebar_enabled', [ $this, 'srfm_global_sidebar_enabled' ] );
79 }
80
81 /**
82 * Add custom API Route submit-form
83 *
84 * @return void
85 * @since 0.0.1
86 */
87 public function register_custom_endpoint() {
88 register_rest_route(
89 $this->namespace,
90 '/submit-form',
91 [
92 'methods' => WP_REST_Server::EDITABLE,
93 'callback' => [ $this, 'handle_form_submission' ],
94 'permission_callback' => [ $this, 'submit_form_permissions_check' ],
95 ]
96 );
97
98 register_rest_route(
99 $this->namespace,
100 '/log-client-error',
101 [
102 'methods' => WP_REST_Server::CREATABLE,
103 'callback' => [ $this, 'handle_client_error_log' ],
104 'permission_callback' => [ $this, 'client_error_log_permissions_check' ],
105 ]
106 );
107 }
108
109 /**
110 * Record an integration failure against the form it happened on.
111 *
112 * Hooked - srfm_integration_failed.
113 *
114 * @param int $form_id Form the submission belongs to.
115 * @param string $reason Short description of what failed.
116 * @since 2.12.6
117 * @return void
118 */
119 public function record_integration_failure( $form_id = 0, $reason = '' ) {
120 $form_id = absint( $form_id );
121
122 Client_Logger::append(
123 Client_Logger::sanitize_entry(
124 [
125 'type' => 'message',
126 'form_id' => $form_id,
127 'form_title' => $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '',
128 'message' => 'Integration failed. ' . Helper::get_string_value( $reason ),
129 ]
130 )
131 );
132
133 Client_Logger::record_failure(
134 'integration',
135 $form_id,
136 $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : ''
137 );
138 }
139
140 /**
141 * Gate the client error log route.
142 *
143 * Order matters. The enabled check runs first and returns 404 rather than 403,
144 * because it is the only thing that actually stops logging: the frontend flag
145 * is baked into cached HTML and can be a full cache TTL out of date, so
146 * switching the setting off does not stop already-cached pages from posting.
147 *
148 * The submit token is then required for consistency with /submit-form, but be
149 * clear about what it buys. It is per-form, not per-visitor, valid for up to
150 * 48 hours, and readable from one GET of any public page carrying the form. It
151 * filters undirected scanners and costs nothing; it is not visitor
152 * authentication. The controls that carry real weight here are the fixed
153 * payload schema in Client_Logger::sanitize_entry() and the rate limit below.
154 *
155 * @param \WP_REST_Request $request Incoming REST request.
156 * @since 2.12.6
157 * @return WP_Error|bool
158 */
159 public function client_error_log_permissions_check( $request ) {
160 if ( ! Client_Logger::is_enabled() ) {
161 return new WP_Error(
162 'srfm_rest_no_route',
163 __( 'Not found.', 'sureforms' ),
164 [ 'status' => 404 ]
165 );
166 }
167
168 $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) );
169 $form_id = absint( $request->get_param( 'form_id' ) );
170
171 if ( ! Submit_Token::verify( $token, $form_id ) ) {
172 return new WP_Error(
173 'srfm_token_invalid',
174 __( 'Security verification failed.', 'sureforms' ),
175 [ 'status' => 403 ]
176 );
177 }
178
179 return true;
180 }
181
182 /**
183 * Record one client-reported form submission failure.
184 *
185 * Always answers 204, whether or not a line was written. The browser has
186 * nothing useful to do with a failure here, and a response that distinguishes
187 * "written" from "dropped" would report back whether logging is on, whether
188 * the log is full, and whether the caller is being throttled.
189 *
190 * @param \WP_REST_Request $request Incoming REST request.
191 * @since 2.12.6
192 * @return \WP_REST_Response
193 */
194 public function handle_client_error_log( $request ) {
195 $response = new \WP_REST_Response( null, 204 );
196
197 $form_id = absint( $request->get_param( 'form_id' ) );
198
199 if ( $this->is_rate_limited( 'srfm_cl_', $form_id ) ) {
200 return $response;
201 }
202
203 $entries = $request->get_param( 'entries' );
204
205 if ( ! is_array( $entries ) ) {
206 return $response;
207 }
208
209 // Cap the batch as well as each entry: a single request must not be able to
210 // consume the whole file and evict the failure someone is trying to capture.
211 foreach ( array_slice( $entries, 0, 10 ) as $raw ) {
212 if ( ! is_array( $raw ) ) {
213 continue;
214 }
215
216 $raw['form_id'] = $form_id;
217
218 // Resolved here rather than sent by the browser: the title is what makes
219 // a log line identifiable at a glance, and taking it from the request
220 // would let a caller label an entry as any form it liked.
221 $raw['form_title'] = $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '';
222
223 Client_Logger::append( Client_Logger::sanitize_entry( $raw ) );
224 }
225
226 return $response;
227 }
228
229 /**
230 * Check whether a given request has permission to submit the form.
231 *
232 * Validates the HMAC-based submission token embedded in the page at render
233 * time. Tokens remain valid for up to 48 hours (four 12-hour windows), so
234 * they survive cached-page scenarios without any browser-side refresh call.
235 *
236 * @param \WP_REST_Request $request Incoming REST request.
237 * @since 2.6.0
238 * @return WP_Error|bool
239 */
240 public function submit_form_permissions_check( $request ) {
241 $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) );
242 $form_id = absint( $request->get_param( 'form-id' ) );
243
244 if ( ! Submit_Token::verify( $token, $form_id ) ) {
245 return new WP_Error(
246 'srfm_token_invalid',
247 __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
248 [ 'status' => 403 ]
249 );
250 }
251
252 return true;
253 }
254
255 /**
256 * Check whether a given request has permission access route.
257 *
258 * @since 0.0.1
259 * @return WP_Error|bool
260 */
261 public function permissions_check() {
262 if ( ! Helper::current_user_can() ) {
263 return new WP_Error( 'rest_forbidden', __( 'Sorry, you do not have permission to access this resource.', 'sureforms' ), [ 'status' => rest_authorization_required_code() ] );
264 }
265 return true;
266 }
267
268 /**
269 * Validate Turnstile token
270 *
271 * @param string $secret_key Turnstile token.
272 * @param string|false $response Response.
273 * @param string|false $remote_ip Remote IP.
274 * @return array<mixed>|mixed Result of the validation.
275 */
276 public static function validate_turnstile_token( $secret_key, $response, $remote_ip ) {
277
278 if ( empty( $secret_key ) || ! is_string( $secret_key ) ) {
279 return [
280 'success' => false,
281 'error' => __( 'Cloudflare Turnstile secret key is invalid.', 'sureforms' ),
282 ];
283 }
284
285 if ( empty( $response ) ) {
286 return [
287 'success' => false,
288 'error' => __( 'Cloudflare Turnstile response is missing.', 'sureforms' ),
289 ];
290 }
291
292 $body = [
293 'secret' => $secret_key,
294 'response' => $response,
295 'remoteip' => $remote_ip,
296 ];
297
298 $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
299
300 $args = [
301 'body' => $body,
302 'timeout' => 15,
303 ];
304
305 $response = wp_remote_post( $url, $args );
306
307 if ( is_wp_error( $response ) ) {
308 $error_message = $response->get_error_message();
309 return [
310 'success' => false,
311 'error' => $error_message,
312 ];
313 }
314
315 return json_decode( wp_remote_retrieve_body( $response ), true );
316 }
317
318 /**
319 * Validate hCaptcha token
320 *
321 * @param string $secret_key hCaptcha token.
322 * @param string|false $response Response.
323 * @param string|false $remote_ip Remote IP.
324 * @since 0.0.5
325 * @return array<mixed>|mixed Result of the validation.
326 */
327 public static function validate_hcaptcha_token( $secret_key, $response, $remote_ip ) {
328
329 if ( empty( $secret_key ) || ! is_string( $secret_key ) ) {
330 return [
331 'success' => false,
332 'error' => __( 'hCaptcha secret key is invalid.', 'sureforms' ),
333 ];
334 }
335
336 if ( empty( $response ) ) {
337 return [
338 'success' => false,
339 'error' => __( 'hCaptcha response is missing.', 'sureforms' ),
340 ];
341 }
342
343 $body = [
344 'secret' => $secret_key,
345 'response' => $response,
346 'remoteip' => $remote_ip,
347 ];
348
349 $url = 'https://api.hcaptcha.com/siteverify';
350
351 $args = [
352 'body' => $body,
353 'timeout' => 15,
354 ];
355
356 $response = wp_remote_post( $url, $args );
357
358 if ( is_wp_error( $response ) ) {
359 $error_message = $response->get_error_message();
360 return [
361 'success' => false,
362 'error' => $error_message,
363 ];
364 }
365
366 return json_decode( wp_remote_retrieve_body( $response ), true );
367 }
368
369 /**
370 * Handle Form Submission
371 *
372 * @param \WP_REST_Request $request Request object or array containing form data.
373 * @since 0.0.1
374 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
375 */
376 public function handle_form_submission( $request ) {
377 $form_data = Helper::sanitize_by_field_type( $request->get_params() );
378
379 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
380 wp_send_json_error( [ 'message' => __( 'Form data is not found.', 'sureforms' ) ] );
381 }
382
383 if ( empty( $form_data['form-id'] ) ) {
384 wp_send_json_error(
385 [
386 'message' => __( 'Form ID is missing.', 'sureforms' ),
387 'position' => 'header',
388 ]
389 );
390 }
391
392 $current_form_id = $form_data['form-id'];
393
394 /**
395 * If someone tries to access the form submit endpoint directly, we need to check if the form is restricted.
396 * If a form is loaded in a browser window and the limit exceeds then the form will not be submitted.
397 */
398 $form_id = Helper::get_integer_value( $current_form_id );
399 if ( Form_Restriction::is_form_restricted( $form_id ) ) {
400 $form_restriction = Form_Restriction::get_form_restriction_setting( $form_id );
401
402 // Get the scheduling state and appropriate message.
403 $scheduling_state = Form_Restriction::get_form_scheduling_state( $form_restriction );
404 $form_restriction_message = Form_Restriction::get_restriction_message_by_state( $scheduling_state, $form_restriction );
405
406 $form_restriction_message = apply_filters( 'srfm_form_restriction_message', $form_restriction_message, $form_id, $form_restriction );
407
408 wp_send_json_error(
409 [
410 'message' => $form_restriction_message,
411 ]
412 );
413 }
414
415 if ( apply_filters( 'srfm_additional_restriction_check', false, $form_id, $form_data ) ) {
416 wp_send_json_error(
417 [
418 'message' => apply_filters( 'srfm_additional_restriction_message', __( 'You do not have permission to submit this form.', 'sureforms' ), $form_id, $form_data ),
419 ]
420 );
421 }
422
423 // Check whether the form is valid.
424 if ( ! Helper::is_valid_form( $current_form_id ) ) {
425 wp_send_json_error(
426 [
427 'code' => 'srfm_invalid_form_id',
428 'message' => __( 'This form is no longer available.', 'sureforms' ),
429 ]
430 );
431 }
432
433 // Drop submitted keys this form does not define before anything consumes them.
434 // Runs on SUBMISSION only, so historical entries whose keys no longer match a
435 // rebuilt form (see #2665) stay fully readable on the read/export paths.
436 $form_data = Field_Validation::strip_unknown_field_keys( $form_data, $current_form_id );
437
438 $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id );
439
440 if ( ! empty( $validated_form_data ) ) {
441 // Get the first error message to display as the main message.
442 $first_error = reset( $validated_form_data );
443
444 wp_send_json_error(
445 [
446 'message' => $first_error ?? __( 'Please check the form for errors.', 'sureforms' ),
447 'field_errors' => $validated_form_data,
448 ]
449 );
450 }
451
452 $security_type = Helper::get_meta_value( Helper::get_integer_value( $current_form_id ), '_srfm_captcha_security_type' );
453 $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 ) ) : '';
454
455 if ( 'none' !== $security_type ) {
456 $global_setting_options = get_option( 'srfm_security_settings_options' );
457 } else {
458 $global_setting_options = [];
459 }
460
461 if ( 'g-recaptcha' === $security_type ) {
462 switch ( $selected_captcha_type ) {
463 case 'v2-checkbox':
464 $key = 'srfm_v2_checkbox_secret_key';
465 break;
466 case 'v2-invisible':
467 $key = 'srfm_v2_invisible_secret_key';
468 break;
469 case 'v3-reCAPTCHA':
470 $key = 'srfm_v3_secret_key';
471 break;
472 default:
473 $key = '';
474 break;
475 }
476
477 $google_captcha_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options[ $key ] ) ? $global_setting_options[ $key ] : '';
478 }
479
480 if ( 'cf-turnstile' === $security_type ) {
481 // Turnstile validation.
482 $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'] ) : '';
483 $cf_response = ! empty( $form_data['cf-turnstile-response'] ) && is_string( $form_data['cf-turnstile-response'] ) ? $form_data['cf-turnstile-response'] : '';
484
485 // if gdpr is enabled then set remote ip to empty.
486 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
487 $gdpr = false;
488
489 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
490 $gdpr = ! empty( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : false;
491 }
492
493 // check if ip logging is disabled in global settings then set remote ip to empty.
494 $gb_general_settinionsgs_opt = get_option( 'srfm_general_settings_options' );
495 $srfm_ip_log = is_array( $gb_general_settinionsgs_opt ) && isset( $gb_general_settinionsgs_opt['srfm_ip_log'] ) ? $gb_general_settinionsgs_opt['srfm_ip_log'] : '';
496
497 $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
498
499 $turnstile_validation_result = self::validate_turnstile_token( $srfm_cf_turnstile_secret_key, $cf_response, $remote_ip );
500
501 // If the cloudflare validation fails, return an error.
502 if ( is_array( $turnstile_validation_result ) && isset( $turnstile_validation_result['success'] ) && false === $turnstile_validation_result['success'] ) {
503 $this->recaptcha_error_response( 'cf-turnstile', $turnstile_validation_result );
504 }
505 }
506
507 if ( 'hcaptcha' === $security_type ) {
508 $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'] ) : '';
509 $hcaptcha_response = ! empty( $form_data['h-captcha-response'] ) && is_string( $form_data['h-captcha-response'] ) ? $form_data['h-captcha-response'] : '';
510
511 // if gdpr is enabled then set remote ip to empty.
512 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
513 $gdpr = false;
514
515 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
516 $gdpr = ! empty( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : false;
517 }
518
519 // check if ip logging is disabled in global settings then set remote ip to empty.
520 $gb_general_settings_options = get_option( 'srfm_general_settings_options' );
521 $srfm_ip_log = is_array( $gb_general_settings_options ) && isset( $gb_general_settings_options['srfm_ip_log'] ) ? $gb_general_settings_options['srfm_ip_log'] : '';
522
523 $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
524 $hcaptcha_validation_result = self::validate_hcaptcha_token( $srfm_hcaptcha_secret_key, $hcaptcha_response, $remote_ip );
525
526 // If the hcaptcha validation fails, return an error.
527 if ( is_array( $hcaptcha_validation_result ) && isset( $hcaptcha_validation_result['success'] ) && false === $hcaptcha_validation_result['success'] ) {
528 $this->recaptcha_error_response( 'hcaptcha', $hcaptcha_validation_result );
529 }
530 }
531
532 if ( isset( $form_data['srfm-honeypot-field'] ) && empty( $form_data['srfm-honeypot-field'] ) ) {
533 if ( ! empty( $google_captcha_secret_key ) ) {
534 if ( ! empty( $form_data['form-id'] ) ) {
535 $secret_key = $google_captcha_secret_key;
536 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
537 $captcha_response = $form_data['g-recaptcha-response'];
538 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
539
540 $response = wp_remote_get( $url );
541
542 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
543 $json_string = wp_remote_retrieve_body( $response );
544 $data = (array) json_decode( $json_string, true );
545 } else {
546 $data = [];
547 }
548 $sureforms_captcha_data = $data;
549
550 } else {
551 wp_send_json_error(
552 [
553 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
554 ]
555 );
556 }
557 if ( isset( $sureforms_captcha_data['success'] ) && true === $sureforms_captcha_data['success'] ) {
558 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
559 }
560
561 $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
562 }
563
564 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
565 }
566
567 if ( ! isset( $form_data['srfm-honeypot-field'] ) ) {
568 // If honeypot is enabled globally, the missing field means a bot stripped it.
569 $srfm_security_options = get_option( 'srfm_security_settings_options' );
570 if ( is_array( $srfm_security_options ) && ! empty( $srfm_security_options['srfm_honeypot'] ) ) {
571 wp_send_json_error(
572 [
573 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ),
574 ]
575 );
576 }
577
578 if ( ! empty( $google_captcha_secret_key ) ) {
579 if ( ! empty( $form_data['form-id'] ) ) {
580 $secret_key = $google_captcha_secret_key;
581 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
582 $captcha_response = $form_data['g-recaptcha-response'];
583 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
584
585 $response = wp_remote_get( $url );
586
587 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
588 $json_string = wp_remote_retrieve_body( $response );
589 $data = (array) json_decode( $json_string, true );
590 } else {
591 $data = [];
592 }
593 $sureforms_captcha_data = $data;
594
595 } else {
596 wp_send_json_error(
597 [
598 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
599 ]
600 );
601 }
602 if ( true === $sureforms_captcha_data['success'] ) {
603 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
604 }
605
606 $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
607 }
608
609 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
610 }
611
612 wp_send_json_error(
613 [
614 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ),
615 ]
616 );
617 }
618
619 /**
620 * Send Email and Create Entry.
621 *
622 * @param array<string> $form_data Request object or array containing form data.
623 * @since 0.0.1
624 * @return array<mixed> Array containing the response data.
625 */
626 public function handle_form_entry( $form_data ) {
627 // Filter the form data.
628 $form_data = apply_filters( 'srfm_form_submit_data', $form_data );
629 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
630 wp_send_json_error(
631 [
632 'message' => __( 'Form data was not found.', 'sureforms' ),
633 'position' => 'header',
634 ]
635 );
636 } elseif ( isset( $form_data['error'] ) ) {
637 wp_send_json_error(
638 [
639 'message' => is_string( $form_data['error'] ) ? $form_data['error'] : __( 'Form data is not found.', 'sureforms' ),
640 'position' => 'header',
641 ]
642 );
643 }
644
645 $id = sanitize_text_field( $form_data['form-id'] );
646
647 // Get the compliance settings.
648 $compliance = get_post_meta( Helper::get_integer_value( $id ), '_srfm_compliance', true );
649 $gdpr = '';
650 $do_not_store_entries = '';
651
652 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
653 $gdpr = $compliance[0]['gdpr'] ?? '';
654 $do_not_store_entries = $compliance[0]['do_not_store_entries'] ?? '';
655 }
656
657 // Check if the form data contains 'srfm_addresses' and is not empty.
658 if ( ! empty( $form_data['srfm_addresses'] ) ) {
659 // Assign the addresses to the class property for further processing.
660 $this->addresses = $form_data['srfm_addresses'];
661 // Remove the address data from the form data to avoid redundancy.
662 unset( $form_data['srfm_addresses'] );
663 }
664
665 $form_data = apply_filters( 'srfm_before_fields_processing', $form_data );
666
667 $submission_data = $this->process_form_fields( $form_data );
668
669 $modified_message = $this->prepare_submission_data( $submission_data );
670
671 $form_before_submission_data = [
672 'form_id' => $id ? intval( $id ) : '',
673 'data' => $modified_message,
674 ];
675
676 /**
677 * Fires before submission process starts.
678 */
679 do_action( 'srfm_before_submission', $form_before_submission_data );
680
681 $name = sanitize_text_field( get_the_title( intval( $id ) ) );
682 $emails = [];
683
684 // Check if GDPR is enabled and do not store entries is enabled.
685 // If so, send email and do not store entries.
686 if ( $gdpr && $do_not_store_entries ) {
687 // Send email before early return. No entry is created in this path so {entry_id} will be empty — that is expected.
688 $send_email = $this->send_email( $id, $submission_data, $form_data );
689 if ( $send_email ) {
690 $emails = $send_email['emails'];
691 }
692
693 $form_submit_response = [
694 'success' => true,
695 'form_id' => $id ? intval( $id ) : '',
696 'to_emails' => $emails,
697 'form_name' => $name ? esc_attr( $name ) : '',
698 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
699 'data' => $modified_message,
700 ];
701
702 do_action( 'srfm_form_submit', $form_submit_response );
703
704 /**
705 * Hook for enabling background processes.
706 *
707 * @param array $form_data form data related to submission.
708 */
709 $form_data['form_id'] = $id ? intval( $id ) : '';
710 do_action( 'srfm_after_submission_process', $form_data );
711
712 return [
713 'success' => true,
714 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
715 'data' => [
716 'name' => $name,
717 'after_submit' => false,
718 ],
719 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ),
720 ];
721
722 }
723
724 $global_setting_options = get_option( 'srfm_general_settings_options' );
725
726 // If GDPR is enabled, do not store IP, browser, device, and submission URL.
727 // If not, store all of them.
728 $user_ip = '';
729 $browser_name = '';
730 $device_name = '';
731 $submission_url = '';
732 if ( ! $gdpr ) {
733 $srfm_ip_log = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_ip_log'] ) ? $global_setting_options['srfm_ip_log'] : '';
734
735 $user_ip = $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
736 $browser = new Browser();
737 $browser_name = sanitize_text_field( $browser->getBrowser() );
738 $device_name = sanitize_text_field( $browser->getPlatform() );
739
740 // Capture submission page URL server-side from the Referer header.
741 // esc_url_raw() (not sanitize_text_field) preserves percent-encoded
742 // non-ASCII slugs; normalize_submission_url() then validates same-origin.
743 $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
744 $submission_url = $this->normalize_submission_url( $referer );
745 }
746
747 $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) );
748 $pattern = '/"label":"(.*?)"/';
749 preg_match_all( $pattern, $form_markup, $matches );
750 $submission_info = [
751 'user_ip' => $user_ip,
752 'browser_name' => $browser_name,
753 'device_name' => $device_name,
754 'submission_url' => $submission_url,
755 ];
756 // Resolve the language the visitor saw at form-render time (captured in a
757 // hidden srfm-form-language input) so the confirmation message and email
758 // notifications below can be rendered in it — WPML's language detection on
759 // the REST submit endpoint frequently falls back to the default. This value
760 // is used only to switch_language() at submit time; it is not persisted. The
761 // hidden input is client-supplied, so:
762 // 1. Validate shape with a BCP-47 regex.
763 // 2. Cross-check against the active multilingual provider's known
764 // languages (active + default) so a crafted request can't switch rendering
765 // to a code the site doesn't support.
766 // 3. Fall back to the provider's current_language() on either failure.
767 $entry_language = Multilingual_Manager::get_instance()->provider()->current_language();
768 $submitted_language = isset( $form_data['srfm-form-language'] ) ? sanitize_text_field( Helper::get_string_value( $form_data['srfm-form-language'] ) ) : '';
769 if ( '' !== $submitted_language && preg_match( '/^[a-z]{2,3}([_-][A-Za-z0-9]{2,8})?$/', $submitted_language ) === 1 && $this->is_known_language( $submitted_language ) ) {
770 $entry_language = $submitted_language;
771 }
772
773 $entries_data = [
774 'form_id' => $id,
775 'form_data' => $submission_data,
776 'submission_info' => $submission_info,
777 'created_at' => current_time( 'mysql' ),
778 ];
779 // Resolved via Helper rather than get_current_user_id() directly: this runs on
780 // a REST request that carries no nonce, which core de-authenticates before
781 // dispatch, so the plain call returns 0 even for a signed-in submitter and the
782 // entry would lose its attribution. Returns 0 when genuinely anonymous.
783 $submitting_user_id = Helper::get_submitting_user_id();
784 if ( $submitting_user_id ) {
785 $entries_data['user_id'] = $submitting_user_id;
786 }
787
788 $entries_data = apply_filters(
789 'srfm_before_entry_data',
790 $entries_data,
791 [
792 'form_data' => $form_data,
793 'submission_data' => $submission_data,
794 ]
795 );
796
797 $entry_id = Entries::add( $entries_data );
798 if ( $entry_id ) {
799 // Inject entry_id so {entry_id} smart tag resolves in confirmation message, redirect URL, email notifications, and downstream integrations.
800 $form_data['entry_id'] = intval( $entry_id );
801
802 // Switch the multilingual provider to the entry's language so the
803 // confirmation message, redirect URL, and email notifications render
804 // in the language the visitor saw at submit time. The REST submit
805 // endpoint doesn't carry the ?lang= URL parameter, so without this
806 // switch the provider would return strings in its default language
807 // even though the visitor filled the form in another language.
808 $provider = Multilingual_Manager::get_instance()->provider();
809 if ( $provider->is_active() && '' !== $entry_language ) {
810 $provider->switch_language( $entry_language );
811 }
812
813 // Send email after entry creation so {entry_id} is available when smart tags are processed.
814 $send_email = $this->send_email( $id, $submission_data, $form_data );
815 if ( $send_email ) {
816 $emails = $send_email['emails'];
817 }
818
819 $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data );
820 $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data );
821
822 if ( $provider->is_active() && '' !== $entry_language ) {
823 $provider->restore_language();
824 }
825
826 $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) );
827
828 $response = [
829 'success' => true,
830 'message' => $confirmation_message,
831 'data' => [
832 'name' => $name,
833 'submission_id' => $entry_id,
834 'after_submit' => true,
835 'after_submit_nonce' => $after_submit_nonce,
836 // Built here rather than assembled in JS. rest_url() already knows
837 // whether the route is a path or a `?rest_route=` query arg, and
838 // add_query_arg() knows whether the nonce needs `?` or `&` — the
839 // client has no way to get either right without reimplementing
840 // both, and concatenating produced a URL that did not route at all
841 // on plain-permalink sites.
842 'after_submit_url' => add_query_arg(
843 'after_submit_nonce',
844 $after_submit_nonce,
845 rest_url( 'sureforms/v1/after-submission/' . Helper::get_integer_value( $entry_id ) )
846 ),
847 ],
848 'redirect_url' => $redirect_url,
849 ];
850
851 $form_submit_response = apply_filters(
852 'srfm_form_submit_response',
853 [
854 'success' => true,
855 'form_id' => $id ? intval( $id ) : '',
856 'entry_id' => intval( $entry_id ),
857 'to_emails' => $emails,
858 'form_name' => $name ? esc_attr( $name ) : '',
859 'message' => $confirmation_message,
860 'data' => $modified_message,
861 ]
862 );
863
864 do_action( 'srfm_form_submit', $form_submit_response );
865 } else {
866 $response = [
867 'success' => false,
868 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ),
869 ];
870 }
871
872 /**
873 * Filter the form submission response.
874 *
875 * @param array<mixed> $response The response data.
876 * @param array<string> $form_data The original form data.
877 * @param array<mixed> $submission_data The processed submission data.
878 * @since 2.4.0
879 */
880 return apply_filters( 'srfm_form_submission_response', $response, $form_data, $submission_data );
881 }
882
883 /**
884 * Prepare submission data.
885 *
886 * @param array<mixed> $submission_data Submission data.
887 * @since 0.0.7
888 * @return array<mixed> Modified submission data.
889 */
890 public function prepare_submission_data( $submission_data ) {
891 $modified_message = [];
892 foreach ( $submission_data as $key => $value ) {
893 $parts = explode( '-lbl-', $key );
894 $label = '';
895
896 /**
897 * Filters submission data for field processing.
898 *
899 * This filter allows customization of how individual fields are processed
900 * during submission data preparation. Plugins can modify field values,
901 * labels, or exclude specific fields from the final submission data.
902 *
903 * @since 1.11.0
904 *
905 * @param array $field_data {
906 * Field data for processing.
907 *
908 * @type array $block_parts The field key split by '-lbl-' delimiter.
909 * @type string $field_key The original field key from submission data.
910 * @type mixed $field_value The field value from submission data.
911 * }
912 */
913 $should_add_field_row = apply_filters(
914 'srfm_prepare_submission_data',
915 [
916 'block_parts' => $parts,
917 'field_key' => $key,
918 'field_value' => $value,
919 ]
920 );
921
922 // If we get the label and value from the filter, then use it.
923 if ( ! empty( $should_add_field_row['label'] ) && ! empty( $should_add_field_row['value'] ) ) {
924 $modified_message[ $should_add_field_row['label'] ] = $should_add_field_row['value'];
925 continue;
926 }
927
928 if ( ! empty( $parts[1] ) ) {
929 $tokens = explode( '-', $parts[1] );
930 if ( count( $tokens ) > 1 ) {
931 $label = implode( '-', array_slice( $tokens, 1 ) );
932 }
933
934 $fields = explode( '-', $parts[0] );
935
936 // Since the upload field returns an array of file URLs, we need to implode them with a comma.
937 if ( 'upload' === $fields[1] && ! empty( $value ) && is_array( $value ) ) {
938 $modified_message[ $label ] = implode( ', ', array_map( 'rawurldecode', $value ) );
939 } else {
940 $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) );
941 }
942 }
943 }
944
945 // If the address is not empty, add it to the submission data.
946 // We are providing this for third-party integrations like Ottokit.
947 // They can use compact addresses such as permanent address, temporary address, etc.
948 // The address will be structured as field 1, field 2, and so on.
949 if ( ! empty( $this->addresses ) ) {
950 // Address will be JSON stringified, so decode it.
951 $address = json_decode( wp_unslash( $this->addresses ), true );
952 if ( ! empty( $address ) && is_array( $address ) ) {
953 $modified_message = array_merge( $modified_message, $address );
954 }
955 }
956
957 return apply_filters( 'srfm_update_prepared_submission_data', $modified_message );
958 }
959
960 /**
961 * Parse an email notification template and generate the necessary components for sending an email.
962 *
963 * @param array<mixed> $submission_data An associative array containing submission data to be used in the email template.
964 * @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'.
965 * @param array<string> $form_data Request object or array containing form data.
966 * @since 1.3.0
967 * @return array<string,string> An associative array containing 'to', 'subject', 'message', and 'headers' for the email.
968 */
969 public static function parse_email_notification_template( $submission_data, $item, $form_data = [] ) {
970 $smart_tags = Smart_Tags::get_instance();
971
972 $to = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_to'], $submission_data ) );
973 $subject = Helper::get_string_value( $smart_tags->process_smart_tags( $item['subject'], $submission_data, $form_data ) );
974 $email_body = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_body'], $submission_data, $form_data ) );
975 $is_raw_format = isset( $item['is_raw_format'] ) && true === $item['is_raw_format'];
976
977 /**
978 * Sanitize the email body after smart tag substitution to prevent XSS.
979 *
980 * After process_smart_tags() resolves {form:slug} placeholders, the body may contain
981 * raw user-submitted values that must not render as executable HTML in email clients.
982 * wp_kses_post() strips dangerous markup (script, on* handlers, javascript: URIs)
983 * while preserving all legitimate email formatting (tables, links, bold, etc.).
984 *
985 * Note: {all_data} is not a recognised smart tag and remains a literal placeholder
986 * at this point; it is substituted later by process_all_data_tag() which applies
987 * its own per-field escaping, so this call does not interfere with that path.
988 *
989 * @since 2.5.2
990 */
991 $email_body = wp_kses_post( $email_body );
992
993 $email_template = new Email_Template();
994 $message = $is_raw_format
995 ? $email_template->render_raw( $submission_data, $email_body )
996 : $email_template->render( $submission_data, $email_body );
997 $headers = 'X-Mailer: PHP/' . phpversion() . "\r\n";
998 $headers .= "Content-Type: text/html; charset=utf-8\r\n";
999
1000 // Add the From: to the headers.
1001 $headers .= self::add_from_data_in_header( $submission_data, $item, $smart_tags );
1002
1003 // Handle Reply-To with proper sanitization.
1004 if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) {
1005 $headers .= 'Reply-To: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) ) ) . "\r\n";
1006 }
1007
1008 // Handle CC with proper sanitization.
1009 if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) {
1010 $headers .= 'Cc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) ) ) . "\r\n";
1011 }
1012
1013 // Handle BCC with proper sanitization.
1014 if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) {
1015 $headers .= 'Bcc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) ) ) . "\r\n";
1016 }
1017
1018 return compact( 'to', 'subject', 'message', 'headers' );
1019 }
1020
1021 /**
1022 * Send Email.
1023 *
1024 * @param string $id Form ID.
1025 * @param array<mixed> $submission_data Submission data.
1026 * @param array<string> $form_data Request object or array containing form data.
1027 * @since 0.0.1
1028 * @return array<mixed> Array containing the response data.
1029 */
1030 public static function send_email( $id, $submission_data, $form_data = [] ) {
1031 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
1032 $is_mail_sent = false;
1033 // Any recipient failing counts as a failure for the whole submission, so
1034 // these are set inside the loop and only read after it.
1035 $notification_failed = false;
1036 // Whether any recipient's "success" came from the mail() fallback, which
1037 // reports true for a message the local MTA accepted and will bounce.
1038 $used_mail_fallback = false;
1039 $emails = [];
1040
1041 // Filter to determine whether the email notification should be sent.
1042 $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data );
1043
1044 if ( is_iterable( $email_notification ) ) {
1045 $entries_db_instance = Entries::get_instance();
1046 $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) );
1047
1048 foreach ( $email_notification as $notification ) {
1049 foreach ( $notification as $item ) {
1050 if ( true === $item['status'] ) {
1051
1052 $parsed = self::parse_email_notification_template( $submission_data, $item, $form_data );
1053
1054 // Allow filtering of the email data before it is sent.
1055 $parsed = apply_filters( 'srfm_email_notification', $parsed, $submission_data, $item, $form_data );
1056
1057 // Trigger an action before sending the email, allowing additional processing or logging.
1058 do_action( 'srfm_before_email_send', $parsed, $submission_data, $item, $form_data );
1059
1060 $notification_id = isset( $item['id'] ) ? intval( $item['id'] ) : 0;
1061
1062 /**
1063 * Filter to determine whether the email should be sent.
1064 *
1065 * @since 1.10.1
1066 */
1067 $should_send_email = apply_filters(
1068 'srfm_should_send_email',
1069 true,
1070 $notification_id,
1071 $id,
1072 $form_data,
1073 );
1074
1075 if ( ! wp_validate_boolean( $should_send_email ) ) {
1076 continue;
1077 }
1078
1079 /**
1080 * Temporary override the content type for wp_mail.
1081 * This helps us from breaking of content type from other plugins.
1082 *
1083 * @since 1.2.2
1084 */
1085 add_filter(
1086 'wp_mail_content_type',
1087 static function() {
1088 return 'text/html'; // We need "text/html" content type to render our emails.
1089 },
1090 99
1091 );
1092
1093 /**
1094 * Start sending email.
1095 * Wrapping it in the buffer because when some plugin such as zoho mail, overrides the wp_mail
1096 * function and any exception is thrown ( Or printed ) from that plugin side, it affects the JSON response.
1097 * So, to make sure such exceptions doesn't affect our JSON response, we are wrapping it inside buffer.
1098 *
1099 * Try-Catch does not work because the notice or errors might be echoed by other plugins rather than thrown as an exception.
1100 *
1101 * @since 1.2.2
1102 */
1103 $sent = false;
1104 ob_start();
1105 $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1106 if ( ! $sent ) {
1107 // Fallback to default PHP mail if for some reasons wp_mail fails.
1108 $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1109
1110 if ( $sent ) {
1111 // Accepted by the local MTA, not delivered. Good
1112 // enough to avoid recording a fault, not good
1113 // enough to retire one.
1114 $used_mail_fallback = true;
1115 }
1116 }
1117 $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
1118
1119 if ( true !== $sent ) {
1120 $notification_failed = true;
1121 }
1122
1123 if ( is_int( $log_key ) ) {
1124 if ( true === $sent ) {
1125 $entries_db_instance->update_log(
1126 $log_key,
1127 null,
1128 [
1129 /* translators: Here, %s is the comma separated emails list. */
1130 sprintf( __( 'Email notification recipient: %s', 'sureforms' ), esc_html( $parsed['to'] ) ),
1131 ]
1132 );
1133 } else {
1134 $reason = ! empty( $email_report )
1135 ? esc_html( $email_report )
1136 : ( ! Helper::is_any_smtp_plugin_active()
1137 ? esc_html__( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' )
1138 : esc_html__( 'Email sending failed for an unknown reason.', 'sureforms' )
1139 );
1140
1141 $entries_db_instance->update_log(
1142 $log_key,
1143 null,
1144 [
1145 sprintf(
1146 /* translators: Here, %1$s is the comma separated emails list and %2$s is error report ( if any ). */
1147 __(
1148 'Email server was unable to send the email notification. Recipient: %1$s. Reason: %2$s',
1149 'sureforms'
1150 ),
1151 esc_html( $parsed['to'] ),
1152 $reason
1153 ),
1154 ]
1155 );
1156
1157 // Also record it in the debug log. The submission itself
1158 // succeeded, so the visitor saw nothing wrong and nobody
1159 // looks at the entry's own log until a ticket is already
1160 // open. The recipient address is not included -- the log
1161 // is downloadable and must not carry personal data.
1162 Client_Logger::append(
1163 Client_Logger::sanitize_entry(
1164 [
1165 'type' => 'message',
1166 'form_id' => intval( $id ),
1167 'form_title' => Helper::get_string_value( get_the_title( intval( $id ) ) ),
1168 'message' => 'Email notification failed to send. ' . $reason,
1169 ]
1170 )
1171 );
1172
1173 // Its own category: the entry saved, so this is not a
1174 // submission failure. The site owner is simply not being
1175 // told about entries they did receive.
1176 Client_Logger::record_failure(
1177 'notification',
1178 intval( $id ),
1179 Helper::get_string_value( get_the_title( intval( $id ) ) )
1180 );
1181 }
1182 }
1183
1184 // Trigger an action after the email is sent, allowing additional processing or logging.
1185 do_action(
1186 'srfm_after_email_send',
1187 $parsed,
1188 $submission_data,
1189 $item,
1190 $form_data
1191 );
1192
1193 $is_mail_sent = $sent;
1194 $emails[] = $parsed['to'];
1195 }
1196 }
1197 }
1198
1199 if ( empty( $emails ) ) {
1200 $entries_db_instance->reset_logs();
1201 $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) );
1202 }
1203
1204 // The notification fault clears when notifications work again. Nothing
1205 // else retired it: Client_Logger::clear_category() had a single caller
1206 // hardcoded to 'submission', and the notice is deliberately not
1207 // dismissible, so a site that had fixed its SMTP kept an undismissable
1208 // banner on every admin page until somebody opened a support ticket.
1209 // Held until the loop is done because one recipient succeeding while
1210 // another fails is still a failure.
1211 //
1212 // Scoped to the form the fault was recorded against. send_email() runs
1213 // on the public submit path and the counter is per category, not per
1214 // form, so without this an anonymous submission of a working form
1215 // wipes a different form's standing fault -- once per admin page load,
1216 // by anyone. The notice names a form, so the granularity is visible
1217 // now that this clears as well as records.
1218 //
1219 // wp_mail() only. The mail() fallback above returns true when the local
1220 // MTA merely accepts a message it will later bounce, which is the
1221 // broken configuration rather than the fixed one.
1222 //
1223 // is_int( $log_key ) mirrors the recording guard: record_failure() sits
1224 // inside it, so without it an install where add_log() returns a
1225 // non-int would never record a notification fault but would still
1226 // clear one.
1227 $open_failures = Client_Logger::get_failures();
1228
1229 if ( ! empty( $emails ) && ! $notification_failed && is_int( $log_key )
1230 && ! $used_mail_fallback
1231 && intval( $id ) === Helper::get_integer_value( $open_failures['notification']['form_id'] ?? 0 ) ) {
1232 Client_Logger::clear_category( 'notification' );
1233 }
1234 }
1235
1236 return [
1237 'success' => $is_mail_sent,
1238 'emails' => $emails,
1239 ];
1240 }
1241
1242 /**
1243 * Validate unique field values for a specific form via AJAX.
1244 *
1245 * Checks submitted field values against existing entries to determine
1246 * if duplicates exist. Rate-limited to prevent data enumeration.
1247 *
1248 * @since 0.0.1
1249 * @since 2.7.0 Added rate limiting, form validation, and optimized query.
1250 * @return void
1251 */
1252 public function field_unique_validation() {
1253 $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
1254 $form_id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1255
1256 if ( ! Submit_Token::verify( $token, $form_id ) ) {
1257 wp_send_json_error( [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ] );
1258 }
1259
1260 if ( ! $form_id ) {
1261 wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] );
1262 }
1263
1264 // Validate the form exists and is published to prevent cross-form probing.
1265 if ( 'publish' !== get_post_status( $form_id ) || 'sureforms_form' !== get_post_type( $form_id ) ) {
1266 wp_send_json_error( [ 'error' => __( 'Invalid form.', 'sureforms' ) ] );
1267 }
1268
1269 // Rate limit: 10 requests per minute per IP per form.
1270 if ( $this->is_unique_validation_rate_limited( $form_id ) ) {
1271 wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 );
1272 }
1273
1274 // SECURITY INVARIANT — only the fields the form itself marks unique may be
1275 // probed through this unauthenticated handler. The allowlist is what keeps the
1276 // lookup scoped to values a site owner opted into checking, rather than to
1277 // stored submission data generally. A form with no unique fields therefore
1278 // matches nothing and always answers with an empty set.
1279 $unique_block_ids = $this->get_unique_field_block_ids( $form_id );
1280
1281 // Extract and validate field values from POST data.
1282 $skip_keys = [ 'action', 'token', 'id' ];
1283 $duplicates = [];
1284
1285 foreach ( $_POST as $raw_key => $raw_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1286 if ( in_array( $raw_key, $skip_keys, true ) ) {
1287 continue;
1288 }
1289
1290 $field_key = str_replace( '_', ' ', sanitize_text_field( $raw_key ) );
1291 $value = sanitize_text_field( wp_unslash( $raw_value ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1292
1293 // Only process SureForms field keys (they contain -lbl- in the name).
1294 if ( false === strpos( $field_key, '-lbl-' ) ) {
1295 continue;
1296 }
1297
1298 if ( '' === $value ) {
1299 continue;
1300 }
1301
1302 // The key must resolve to a block this form configured as unique.
1303 $block_id = Helper::get_block_id_from_key( $field_key );
1304
1305 if ( '' === $block_id || ! isset( $unique_block_ids[ $block_id ] ) ) {
1306 continue;
1307 }
1308
1309 // Single optimized query per field instead of loading all entries.
1310 if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) {
1311 $duplicates[] = [ $field_key => 'not unique' ];
1312 }
1313 }
1314
1315 wp_send_json( [ 'data' => $duplicates ] );
1316 }
1317
1318 /**
1319 * Function to save allowed block data.
1320 *
1321 * @since 0.0.1
1322 * @return void
1323 */
1324 public function srfm_global_update_allowed_block() {
1325 if ( ! Helper::current_user_can() ) {
1326 wp_send_json_error();
1327 }
1328
1329 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1330 wp_send_json_error();
1331 }
1332
1333 if ( ! empty( $_POST['defaultAllowedQuickSidebarBlocks'] ) ) {
1334 $srfm_default_allowed_quick_sidebar_blocks = json_decode( sanitize_text_field( wp_unslash( $_POST['defaultAllowedQuickSidebarBlocks'] ) ), true );
1335 Helper::update_admin_settings_option( 'srfm_quick_sidebar_allowed_blocks', $srfm_default_allowed_quick_sidebar_blocks );
1336 wp_send_json_success();
1337 }
1338 wp_send_json_error();
1339 }
1340
1341 /**
1342 * Function to save enable/disable data.
1343 *
1344 * @since 0.0.1
1345 * @return void
1346 */
1347 public function srfm_global_sidebar_enabled() {
1348 if ( ! Helper::current_user_can() ) {
1349 wp_send_json_error();
1350 }
1351
1352 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1353 wp_send_json_error();
1354 }
1355
1356 if ( ! empty( $_POST['enableQuickActionSidebar'] ) ) {
1357 $srfm_enable_quick_action_sidebar = ( 'enabled' === $_POST['enableQuickActionSidebar'] ? 'enabled' : 'disabled' );
1358 Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar );
1359 wp_send_json_success();
1360 }
1361 wp_send_json_error();
1362 }
1363
1364 /**
1365 * Send error response for reCAPTCHA validation failure.
1366 *
1367 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1368 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1369 * @since 1.7.0
1370 * @return void
1371 */
1372 public function recaptcha_error_response( $type, $api_response ) {
1373 $error_message = $this->recaptcha_error_message( $type, $api_response );
1374 $response = array_merge(
1375 [
1376 'api_response' => $api_response,
1377 ],
1378 $error_message
1379 );
1380
1381 wp_send_json_error( $response );
1382 }
1383
1384 /**
1385 * Get the error message for a CAPTCHA validation failure based on the service type and API response.
1386 *
1387 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1388 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1389 * @since 1.7.0
1390 * @return array<string,string> An associative array containing the error message and a detailed message.
1391 */
1392 public function recaptcha_error_message( $type, $api_response ) {
1393
1394 if ( empty( $api_response['error-codes'] ) || ! is_array( $api_response['error-codes'] ) ) {
1395 return [
1396 'detail_message' => __( 'Captcha validation failed. No error code provided.', 'sureforms' ),
1397 'message' => __( 'Captcha validation failed.', 'sureforms' ),
1398 ];
1399 }
1400
1401 /**
1402 * Note: The error codes are not translated because these messages are intended for debugging purposes.
1403 * Translating them would make debugging difficult. These error messages are primarily for developers or administrators.
1404 * A generic message will be displayed to the user, while detailed error information will be logged or shown in the console.
1405 */
1406
1407 // Google reCAPTCHA error codes.
1408 // Reference: (https://developers.google.com/recaptcha/docs/verify#error-code-reference).
1409 $google_recaptcha_error = [
1410 'missing-input-secret' => 'The secret parameter is missing.',
1411 'invalid-input-secret' => 'The secret parameter is invalid or malformed.',
1412 'missing-input-response' => 'The response parameter is missing.',
1413 'invalid-input-response' => 'The response parameter is invalid or malformed.',
1414 'bad-request' => 'The request is invalid or malformed.',
1415 'timeout-or-duplicate' => 'The response is no longer valid: either is too old or has been used previously.',
1416 ];
1417
1418 // hCaptcha error codes.
1419 // Reference: (https://docs.hcaptcha.com/#siteverify-error-codes).
1420 $hcaptcha_errors = [
1421 'missing-input-secret' => 'Your secret key is missing.',
1422 'invalid-input-secret' => 'Your secret key is invalid or malformed.',
1423 'missing-input-response' => 'The response parameter (verification token) is missing.',
1424 'invalid-input-response' => 'The response parameter (verification token) is invalid or malformed.',
1425 'expired-input-response' => 'The response parameter (verification token) is expired. (120s default)',
1426 'already-seen-response' => 'The response parameter (verification token) was already verified once.',
1427 'bad-request' => 'The request is invalid or malformed.',
1428 'missing-remoteip' => 'The remoteip parameter is missing.',
1429 'invalid-remoteip' => 'The remoteip parameter is not a valid IP address or blinded value.',
1430 'not-using-dummy-passcode' => 'You have used a testing sitekey but have not used its matching secret.',
1431 'sitekey-secret-mismatch' => 'The sitekey is not registered with the provided secret.',
1432 ];
1433
1434 // Cloudflare Turnstile error codes.
1435 // Reference: (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).
1436 $cf_turnstile_errors = [
1437 'missing-input-secret' => 'The secret parameter was not passed.',
1438 'invalid-input-secret' => 'The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.',
1439 'missing-input-response' => 'The response parameter (token) was not passed.',
1440 '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.',
1441 'bad-request' => 'The request was rejected because it was malformed.',
1442 '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.',
1443 'internal-error' => 'An internal error happened while validating the response. The request can be retried.',
1444 ];
1445
1446 $error_code = $api_response['error-codes'][0] ?? 'no-error-code';
1447
1448 $captcha_title = '';
1449 $captcha_message = '';
1450 switch ( $type ) {
1451 case 'g-recaptcha':
1452 $captcha_title = __( 'Google reCAPTCHA', 'sureforms' );
1453 $captcha_message = $google_recaptcha_error[ $error_code ];
1454 break;
1455 case 'hcaptcha':
1456 $captcha_title = __( 'hCaptcha', 'sureforms' );
1457 $captcha_message = $hcaptcha_errors[ $error_code ];
1458 break;
1459 case 'cf-turnstile':
1460 $captcha_title = __( 'Cloudflare Turnstile', 'sureforms' );
1461 $captcha_message = $cf_turnstile_errors[ $error_code ];
1462 break;
1463 default:
1464 $captcha_title = __( 'Unknown Captcha', 'sureforms' );
1465 $captcha_message = __( 'Invalid captcha type.', 'sureforms' );
1466 break;
1467 }
1468
1469 $detail_message = sprintf(
1470 '%s: %s <br> Error Code: %s',
1471 $captcha_title,
1472 $captcha_message ?? 'Unknown error occurred.',
1473 $error_code
1474 );
1475
1476 $message = sprintf(
1477 /* translators: %s is the captcha title. */
1478 __( '%s verification failed. Please contact your site administrator.', 'sureforms' ),
1479 $captcha_title
1480 );
1481
1482 return [
1483 'log_message' => $detail_message, // This variable is used for logging purposes, such as displaying detailed error information in the console on the front end.
1484 'message' => $message,
1485 ];
1486 }
1487
1488 /**
1489 * Sanitise and validate a Referer into a storable submission URL.
1490 *
1491 * The value is rebuilt from parsed components so a non-browser client cannot
1492 * inject bits a real browser would never send (userinfo, fragment) or mismatch
1493 * the legitimate origin's port. Anything that is not a same-origin http(s) URL,
1494 * or is longer than 2048 chars, is rejected and returns an empty string.
1495 *
1496 * Uses esc_url_raw() rather than sanitize_text_field(): the latter strips
1497 * percent-encoded octets (`%E0%A4...`), which mangles the URLs of translated
1498 * pages whose slugs contain non-ASCII characters (e.g. WPML Hindi/Arabic
1499 * permalinks) down to bare hyphens. esc_url_raw() preserves the percent-encoding
1500 * so the recorded submission URL stays accurate.
1501 *
1502 * @param string $referer Raw (unslashed) Referer header value.
1503 * @since 2.11.0
1504 * @return string Same-origin http(s) URL, or empty string when invalid.
1505 */
1506 protected function normalize_submission_url( string $referer ): string {
1507 $referer = esc_url_raw( $referer );
1508
1509 if ( '' === $referer || strlen( $referer ) > 2048 ) {
1510 return '';
1511 }
1512
1513 $parts = wp_parse_url( $referer );
1514 $home_parts = wp_parse_url( home_url() );
1515
1516 if (
1517 ! is_array( $parts )
1518 || ! is_array( $home_parts )
1519 || ! isset( $parts['scheme'], $parts['host'], $home_parts['host'] )
1520 || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true )
1521 || 0 !== strcasecmp( (string) $parts['host'], (string) $home_parts['host'] )
1522 || ( $parts['port'] ?? null ) !== ( $home_parts['port'] ?? null )
1523 ) {
1524 return '';
1525 }
1526
1527 $clean = $parts['scheme'] . '://' . $parts['host']
1528 . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' )
1529 . ( $parts['path'] ?? '' )
1530 . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
1531
1532 return esc_url_raw( $clean, [ 'http', 'https' ] );
1533 }
1534
1535 /**
1536 * Check whether the given language code is known to the active multilingual
1537 * provider (i.e. in its active-languages set or matches the default language).
1538 *
1539 * Used to reject crafted srfm-form-language hidden-input values that pass
1540 * the BCP-47 shape regex but reference languages the site doesn't actually
1541 * support.
1542 *
1543 * @param string $language Language code to check (e.g. 'hi', 'de-AT').
1544 * @since 2.11.0
1545 * @return bool True when the code is known, false otherwise.
1546 */
1547 protected function is_known_language( string $language ): bool {
1548 if ( '' === $language ) {
1549 return false;
1550 }
1551
1552 $provider = Multilingual_Manager::get_instance()->provider();
1553
1554 // When no provider is active there's no authoritative set to check
1555 // against. Accept whatever the visitor sent (shape-validated) so the
1556 // column still reflects the visitor's intent on non-WPML sites.
1557 if ( ! $provider->is_active() ) {
1558 return true;
1559 }
1560
1561 // Default language is always considered known.
1562 if ( $language === $provider->default_language() ) {
1563 return true;
1564 }
1565
1566 // Use WPML's filter when available — works regardless of which
1567 // multilingual plugin is the active provider, as Polylang implements
1568 // the same filter for compatibility.
1569 $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.
1570 if ( is_array( $active ) && ! empty( $active ) ) {
1571 return array_key_exists( $language, $active );
1572 }
1573
1574 // A provider IS active but its language list is unavailable. Rather than
1575 // fail open and trust an arbitrary client-supplied code, accept it only when
1576 // it matches the server-resolved current language. The caller already
1577 // defaults $entry_language to current_language(), so this keeps mis-tagging
1578 // to the server's own determination instead of the (cacheable) client value.
1579 return $language === $provider->current_language();
1580 }
1581
1582 /**
1583 * Collect the block IDs of the fields a form configures as unique.
1584 *
1585 * Derived from the stored form, never from the request — the whole point is that
1586 * the client cannot nominate which fields are probeable. The frontend already
1587 * sends only inputs rendered with data-unique="true", which comes from the same
1588 * isUnique attribute, so this is the server-side mirror of what the client does.
1589 *
1590 * @param int $form_id Form ID.
1591 *
1592 * @since 2.12.3
1593 * @return array<string,true> Unique field block IDs, keyed by block ID.
1594 */
1595 private function get_unique_field_block_ids( $form_id ) {
1596 $form = get_post( $form_id );
1597
1598 if ( ! $form instanceof \WP_Post || '' === $form->post_content ) {
1599 return [];
1600 }
1601
1602 $visited_refs = [];
1603 $block_ids = $this->collect_unique_field_block_ids( parse_blocks( $form->post_content ), $visited_refs );
1604
1605 /**
1606 * Filters the block IDs treated as unique fields for the AJAX uniqueness check.
1607 *
1608 * Lets add-ons whose fields a static parse of the form cannot see contribute
1609 * their own unique fields.
1610 *
1611 * @since 2.12.3
1612 *
1613 * @param array<string,true> $block_ids Unique field block IDs, keyed by block ID.
1614 * A plain list of IDs is accepted too and is
1615 * normalised to this shape.
1616 * @param int $form_id Form ID.
1617 */
1618 $filtered = apply_filters( 'srfm_unique_field_block_ids', $block_ids, $form_id );
1619
1620 // Normalise rather than trust: the lookup is isset( $set[ $block_id ] ), so an
1621 // add-on returning a plain list would silently disable uniqueness for the form
1622 // instead of adding to it. A non-array return keeps the derived set.
1623 return is_array( $filtered ) ? self::normalize_block_id_set( $filtered ) : $block_ids;
1624 }
1625
1626 /**
1627 * Normalise a block-ID collection to a block ID => true map.
1628 *
1629 * Accepts both the documented map shape and a plain list of IDs.
1630 *
1631 * @param array<mixed> $block_ids Block IDs as a map or a list.
1632 *
1633 * @since 2.12.3
1634 * @return array<string,true> Block IDs keyed by block ID.
1635 */
1636 private static function normalize_block_id_set( $block_ids ) {
1637 $normalized = [];
1638
1639 foreach ( $block_ids as $key => $value ) {
1640 // List entry: the ID is the value. Map entry: the ID is the key.
1641 $block_id = is_int( $key ) ? $value : $key;
1642
1643 if ( is_string( $block_id ) && '' !== $block_id ) {
1644 $normalized[ $block_id ] = true;
1645 }
1646 }
1647
1648 return $normalized;
1649 }
1650
1651 /**
1652 * Recursively collect block IDs of blocks whose isUnique attribute is enabled.
1653 *
1654 * Recurses into innerBlocks (repeater/container children) and expands
1655 * reusable/synced patterns, mirroring Form_Styling::collect_form_block_ids().
1656 *
1657 * Note: parse_blocks() does NOT apply block.json defaults, unlike the render path.
1658 * Every field block therefore has to keep isUnique defaulting to false — a block
1659 * that defaults it to true would be serialised without the attribute and would be
1660 * missed here while still rendering data-unique="true".
1661 *
1662 * @param array<mixed> $blocks Parsed blocks from parse_blocks().
1663 * @param array<int, true> $visited_refs Reusable-block post IDs already expanded,
1664 * keyed by ID — guards against reference cycles.
1665 *
1666 * @since 2.12.3
1667 * @return array<string,true> Unique field block IDs, keyed by block ID.
1668 */
1669 private function collect_unique_field_block_ids( $blocks, &$visited_refs = [] ) {
1670 $block_ids = [];
1671
1672 foreach ( $blocks as $block ) {
1673 if ( ! is_array( $block ) ) {
1674 continue;
1675 }
1676
1677 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
1678
1679 if ( ! empty( $attrs['isUnique'] ) && ! empty( $attrs['block_id'] ) && is_scalar( $attrs['block_id'] ) ) {
1680 $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true;
1681 }
1682
1683 // Reusable/synced pattern: expand the referenced wp_block post so a field
1684 // living inside a pattern is seen like an inline block.
1685 if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
1686 $ref = absint( $attrs['ref'] );
1687
1688 if ( $ref && ! isset( $visited_refs[ $ref ] ) ) {
1689 $visited_refs[ $ref ] = true;
1690 $ref_post = get_post( $ref );
1691
1692 if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && 'publish' === $ref_post->post_status && '' !== $ref_post->post_content ) {
1693 $block_ids += $this->collect_unique_field_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs );
1694 }
1695 }
1696 }
1697
1698 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1699 $block_ids += $this->collect_unique_field_block_ids( $block['innerBlocks'], $visited_refs );
1700 }
1701 }
1702
1703 return $block_ids;
1704 }
1705
1706 /**
1707 * Check if the current request is rate-limited for unique validation.
1708 *
1709 * Uses transients keyed by IP + form ID to throttle requests.
1710 * Allows 10 requests per 60-second window per IP per form.
1711 *
1712 * @param int $form_id The form ID being validated.
1713 * @since 2.7.0
1714 * @return bool True if rate-limited (should block), false if allowed.
1715 */
1716 private function is_unique_validation_rate_limited( $form_id ) {
1717 return $this->is_rate_limited( 'srfm_uv_', $form_id );
1718 }
1719
1720 /**
1721 * Throttle a public endpoint to 10 requests per minute per IP per form.
1722 *
1723 * Shared by the uniqueness check and the client log route rather than
1724 * duplicated, so a change to the window applies to both.
1725 *
1726 * @param string $prefix Transient key prefix, unique per endpoint.
1727 * @param int $form_id The form ID the request relates to.
1728 * @since 2.12.6
1729 * @return bool True if rate-limited (should block), false if allowed.
1730 */
1731 private function is_rate_limited( $prefix, $form_id ) {
1732 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
1733
1734 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1735 return true; // Fail closed if IP cannot be determined.
1736 }
1737
1738 $transient_key = $prefix . md5( $ip . '_' . $form_id );
1739 $attempts = get_transient( $transient_key );
1740
1741 if ( false === $attempts ) {
1742 set_transient( $transient_key, 1, MINUTE_IN_SECONDS );
1743 return false;
1744 }
1745
1746 $attempts_count = Helper::get_integer_value( $attempts );
1747
1748 if ( $attempts_count >= 10 ) {
1749 return true;
1750 }
1751
1752 set_transient( $transient_key, $attempts_count + 1, MINUTE_IN_SECONDS );
1753 return false;
1754 }
1755
1756 /**
1757 * Process and sanitize SureForms field data from submitted form data.
1758 *
1759 * @param array<mixed> $form_data Raw form data from submission.
1760 *
1761 * @since 1.11.0
1762 * @return array Processed and sanitized submission data.
1763 */
1764 private function process_form_fields( $form_data ) {
1765 $form_id = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? absint( $form_data['form-id'] ) : 0;
1766
1767 $submission_data = [];
1768
1769 $form_data_keys = array_keys( $form_data );
1770 $form_data_count = count( $form_data );
1771
1772 for ( $i = 0; $i < $form_data_count; $i++ ) {
1773 $key = strval( $form_data_keys[ $i ] );
1774
1775 /**
1776 * This will allow to pass only sureforms fields
1777 * checking -lbl- as thats mandatory for in key of sureforms fields.
1778 */
1779 if ( false === str_contains( $key, '-lbl-' ) ) {
1780 continue;
1781 }
1782
1783 $value = $form_data[ $key ];
1784
1785 $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
1786
1787 $field_block_name = Helper::get_block_name_from_field( $field_name );
1788
1789 /**
1790 * Filters the field value during form submission processing.
1791 *
1792 * This filter allows the Pro plugin to process and modify field values before they are saved.
1793 * The Pro plugin can implement custom sanitization, validation and escaping logic for its
1794 * specialized field types. When this filter is used by Pro, the core plugin will skip its
1795 * default validation.
1796 *
1797 * @since 1.11.0
1798 *
1799 * @param mixed $value The raw field value from form submission.
1800 * @param array $field_data Field information array containing:
1801 * - 'field_name': The field name/key
1802 * - 'field_block_name': The block type identifier
1803 * @return array {
1804 * Processed field value data
1805 *
1806 * @type bool $is_processed Whether the value was processed by Pro plugin
1807 * @type mixed $value The processed and sanitized field value
1808 * }
1809 */
1810 $process_field_value = apply_filters(
1811 'srfm_process_field_value',
1812 $value,
1813 [
1814 'field_name' => $field_name,
1815 'field_block_name' => $field_block_name,
1816 ]
1817 );
1818
1819 if ( is_array( $process_field_value ) && ! empty( $process_field_value['is_processed'] ) && ! empty( $process_field_value['value'] ) ) {
1820 $submission_data[ $field_name ] = $process_field_value['value'];
1821 continue;
1822 }
1823
1824 /**
1825 * Need to remove this refactor array value handling.
1826 *
1827 * The current array-based value handling needs to be replaced with:
1828 * 1. Block-specific value processing based on block type.
1829 * 2. Move premium features to pro version.
1830 * 3. Implement value processing through filters for extensibility.
1831 *
1832 * This will improve code organization and maintainability while properly
1833 * separating free/pro functionality.
1834 */
1835
1836 // If the field is an array, encode the values. This is to add support for multi-upload field.
1837 if ( is_array( $value ) ) {
1838 $submission_data[ $field_name ] =
1839 array_map(
1840 static function ( $val ) {
1841 return rawurlencode( $val );
1842 },
1843 $value
1844 );
1845 } else {
1846 $submission_data[ $field_name ] = is_string( $value ) ? htmlspecialchars( $value ) : $value;
1847 }
1848 }
1849
1850 /**
1851 * Filters the submission data before preparing it for storage.
1852 *
1853 * The second parameter is a context array containing additional metadata
1854 * about the submission. This array is extensible — new keys may be added
1855 * in future versions without changing the filter signature.
1856 *
1857 * @since 2.6.0
1858 *
1859 * @param array<string,mixed> $submission_data Processed form submission data.
1860 * @param array<string,mixed> $context {
1861 * Additional context for the submission.
1862 *
1863 * @type int $form_id The ID of the form being submitted.
1864 * }
1865 */
1866 return apply_filters(
1867 'srfm_before_prepare_submission_data',
1868 $submission_data,
1869 [
1870 'form_id' => $form_id,
1871 ]
1872 );
1873 }
1874
1875 /**
1876 * Add From email and name in the header.
1877 *
1878 * @param array<mixed> $submission_data Submission data.
1879 * @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'.
1880 * @param Smart_Tags $smart_tags Smart Tags instance.
1881 * @since 1.6.1
1882 * @return string The formatted "From" email header.
1883 */
1884 private static function add_from_data_in_header( $submission_data, $item, $smart_tags ) {
1885 $from_name = is_array( $item ) && ! empty( $item['from_name'] ) ? sanitize_text_field( Helper::get_string_value( $item['from_name'] ) ) : '{site_title}';
1886 $from_email = is_array( $item ) && ! empty( $item['from_email'] ) ? Helper::get_string_value( $item['from_email'] ) : '{admin_email}';
1887
1888 // Check if the email contains smart tags. If not, validate the email.
1889 $is_valid_email = true;
1890 if ( ! str_contains( $from_email, '{' ) && ! str_contains( $from_email, '}' ) ) {
1891 $is_valid_email = filter_var( $from_email, FILTER_VALIDATE_EMAIL );
1892 }
1893 // if the email is not valid, set it to the admin email.
1894 if ( ! $is_valid_email ) {
1895 $from_email = Helper::get_string_value( get_option( 'admin_email' ) );
1896 }
1897
1898 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";
1899 }
1900 }
1901