PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
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.8, at inc/form-submit.php

1,915 lines 71.8 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 // Entries::add() has stored the logs collected so far. Start the instance
814 // empty so the update below writes only what send_email() records --
815 // Entries::update() merges with the stored logs, so anything left here
816 // would be written twice.
817 $entries_db_instance = Entries::get_instance();
818 $entries_db_instance->reset_logs();
819
820 // Send email after entry creation so {entry_id} is available when smart tags are processed.
821 $send_email = $this->send_email( $id, $submission_data, $form_data );
822 if ( $send_email ) {
823 $emails = $send_email['emails'];
824 }
825
826 // send_email() logs to the in-memory instance; the entry already exists,
827 // so the log only reaches it through an update.
828 $notification_logs = $entries_db_instance->get_logs();
829 if ( ! empty( $notification_logs ) ) {
830 Entries::update( Helper::get_integer_value( $entry_id ), [ 'logs' => $notification_logs ] );
831 }
832
833 $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data );
834 $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data );
835
836 if ( $provider->is_active() && '' !== $entry_language ) {
837 $provider->restore_language();
838 }
839
840 $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) );
841
842 $response = [
843 'success' => true,
844 'message' => $confirmation_message,
845 'data' => [
846 'name' => $name,
847 'submission_id' => $entry_id,
848 'after_submit' => true,
849 'after_submit_nonce' => $after_submit_nonce,
850 // Built here rather than assembled in JS. rest_url() already knows
851 // whether the route is a path or a `?rest_route=` query arg, and
852 // add_query_arg() knows whether the nonce needs `?` or `&` — the
853 // client has no way to get either right without reimplementing
854 // both, and concatenating produced a URL that did not route at all
855 // on plain-permalink sites.
856 'after_submit_url' => add_query_arg(
857 'after_submit_nonce',
858 $after_submit_nonce,
859 rest_url( 'sureforms/v1/after-submission/' . Helper::get_integer_value( $entry_id ) )
860 ),
861 ],
862 'redirect_url' => $redirect_url,
863 ];
864
865 $form_submit_response = apply_filters(
866 'srfm_form_submit_response',
867 [
868 'success' => true,
869 'form_id' => $id ? intval( $id ) : '',
870 'entry_id' => intval( $entry_id ),
871 'to_emails' => $emails,
872 'form_name' => $name ? esc_attr( $name ) : '',
873 'message' => $confirmation_message,
874 'data' => $modified_message,
875 ]
876 );
877
878 do_action( 'srfm_form_submit', $form_submit_response );
879 } else {
880 $response = [
881 'success' => false,
882 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ),
883 ];
884 }
885
886 /**
887 * Filter the form submission response.
888 *
889 * @param array<mixed> $response The response data.
890 * @param array<string> $form_data The original form data.
891 * @param array<mixed> $submission_data The processed submission data.
892 * @since 2.4.0
893 */
894 return apply_filters( 'srfm_form_submission_response', $response, $form_data, $submission_data );
895 }
896
897 /**
898 * Prepare submission data.
899 *
900 * @param array<mixed> $submission_data Submission data.
901 * @since 0.0.7
902 * @return array<mixed> Modified submission data.
903 */
904 public function prepare_submission_data( $submission_data ) {
905 $modified_message = [];
906 foreach ( $submission_data as $key => $value ) {
907 $parts = explode( '-lbl-', $key );
908 $label = '';
909
910 /**
911 * Filters submission data for field processing.
912 *
913 * This filter allows customization of how individual fields are processed
914 * during submission data preparation. Plugins can modify field values,
915 * labels, or exclude specific fields from the final submission data.
916 *
917 * @since 1.11.0
918 *
919 * @param array $field_data {
920 * Field data for processing.
921 *
922 * @type array $block_parts The field key split by '-lbl-' delimiter.
923 * @type string $field_key The original field key from submission data.
924 * @type mixed $field_value The field value from submission data.
925 * }
926 */
927 $should_add_field_row = apply_filters(
928 'srfm_prepare_submission_data',
929 [
930 'block_parts' => $parts,
931 'field_key' => $key,
932 'field_value' => $value,
933 ]
934 );
935
936 // If we get the label and value from the filter, then use it.
937 if ( ! empty( $should_add_field_row['label'] ) && ! empty( $should_add_field_row['value'] ) ) {
938 $modified_message[ $should_add_field_row['label'] ] = $should_add_field_row['value'];
939 continue;
940 }
941
942 if ( ! empty( $parts[1] ) ) {
943 $tokens = explode( '-', $parts[1] );
944 if ( count( $tokens ) > 1 ) {
945 $label = implode( '-', array_slice( $tokens, 1 ) );
946 }
947
948 $fields = explode( '-', $parts[0] );
949
950 // Since the upload field returns an array of file URLs, we need to implode them with a comma.
951 if ( 'upload' === $fields[1] && ! empty( $value ) && is_array( $value ) ) {
952 $modified_message[ $label ] = implode( ', ', array_map( 'rawurldecode', $value ) );
953 } else {
954 $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) );
955 }
956 }
957 }
958
959 // If the address is not empty, add it to the submission data.
960 // We are providing this for third-party integrations like Ottokit.
961 // They can use compact addresses such as permanent address, temporary address, etc.
962 // The address will be structured as field 1, field 2, and so on.
963 if ( ! empty( $this->addresses ) ) {
964 // Address will be JSON stringified, so decode it.
965 $address = json_decode( wp_unslash( $this->addresses ), true );
966 if ( ! empty( $address ) && is_array( $address ) ) {
967 $modified_message = array_merge( $modified_message, $address );
968 }
969 }
970
971 return apply_filters( 'srfm_update_prepared_submission_data', $modified_message );
972 }
973
974 /**
975 * Parse an email notification template and generate the necessary components for sending an email.
976 *
977 * @param array<mixed> $submission_data An associative array containing submission data to be used in the email template.
978 * @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'.
979 * @param array<string> $form_data Request object or array containing form data.
980 * @since 1.3.0
981 * @return array<string,string> An associative array containing 'to', 'subject', 'message', and 'headers' for the email.
982 */
983 public static function parse_email_notification_template( $submission_data, $item, $form_data = [] ) {
984 $smart_tags = Smart_Tags::get_instance();
985
986 $to = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_to'], $submission_data ) );
987 $subject = Helper::get_string_value( $smart_tags->process_smart_tags( $item['subject'], $submission_data, $form_data ) );
988 $email_body = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_body'], $submission_data, $form_data ) );
989 $is_raw_format = isset( $item['is_raw_format'] ) && true === $item['is_raw_format'];
990
991 /**
992 * Sanitize the email body after smart tag substitution to prevent XSS.
993 *
994 * After process_smart_tags() resolves {form:slug} placeholders, the body may contain
995 * raw user-submitted values that must not render as executable HTML in email clients.
996 * wp_kses_post() strips dangerous markup (script, on* handlers, javascript: URIs)
997 * while preserving all legitimate email formatting (tables, links, bold, etc.).
998 *
999 * Note: {all_data} is not a recognised smart tag and remains a literal placeholder
1000 * at this point; it is substituted later by process_all_data_tag() which applies
1001 * its own per-field escaping, so this call does not interfere with that path.
1002 *
1003 * @since 2.5.2
1004 */
1005 $email_body = wp_kses_post( $email_body );
1006
1007 $email_template = new Email_Template();
1008 $message = $is_raw_format
1009 ? $email_template->render_raw( $submission_data, $email_body )
1010 : $email_template->render( $submission_data, $email_body );
1011 $headers = 'X-Mailer: PHP/' . phpversion() . "\r\n";
1012 $headers .= "Content-Type: text/html; charset=utf-8\r\n";
1013
1014 // Add the From: to the headers.
1015 $headers .= self::add_from_data_in_header( $submission_data, $item, $smart_tags );
1016
1017 // Handle Reply-To with proper sanitization.
1018 if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) {
1019 $headers .= 'Reply-To: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) ) ) . "\r\n";
1020 }
1021
1022 // Handle CC with proper sanitization.
1023 if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) {
1024 $headers .= 'Cc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) ) ) . "\r\n";
1025 }
1026
1027 // Handle BCC with proper sanitization.
1028 if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) {
1029 $headers .= 'Bcc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) ) ) . "\r\n";
1030 }
1031
1032 return compact( 'to', 'subject', 'message', 'headers' );
1033 }
1034
1035 /**
1036 * Send Email.
1037 *
1038 * @param string $id Form ID.
1039 * @param array<mixed> $submission_data Submission data.
1040 * @param array<string> $form_data Request object or array containing form data.
1041 * @since 0.0.1
1042 * @return array<mixed> Array containing the response data.
1043 */
1044 public static function send_email( $id, $submission_data, $form_data = [] ) {
1045 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
1046 $is_mail_sent = false;
1047 // Any recipient failing counts as a failure for the whole submission, so
1048 // these are set inside the loop and only read after it.
1049 $notification_failed = false;
1050 // Whether any recipient's "success" came from the mail() fallback, which
1051 // reports true for a message the local MTA accepted and will bounce.
1052 $used_mail_fallback = false;
1053 $emails = [];
1054
1055 // Filter to determine whether the email notification should be sent.
1056 $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data );
1057
1058 if ( is_iterable( $email_notification ) ) {
1059 $entries_db_instance = Entries::get_instance();
1060 $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) );
1061
1062 foreach ( $email_notification as $notification ) {
1063 foreach ( $notification as $item ) {
1064 if ( true === $item['status'] ) {
1065
1066 $parsed = self::parse_email_notification_template( $submission_data, $item, $form_data );
1067
1068 // Allow filtering of the email data before it is sent.
1069 $parsed = apply_filters( 'srfm_email_notification', $parsed, $submission_data, $item, $form_data );
1070
1071 // Trigger an action before sending the email, allowing additional processing or logging.
1072 do_action( 'srfm_before_email_send', $parsed, $submission_data, $item, $form_data );
1073
1074 $notification_id = isset( $item['id'] ) ? intval( $item['id'] ) : 0;
1075
1076 /**
1077 * Filter to determine whether the email should be sent.
1078 *
1079 * @since 1.10.1
1080 */
1081 $should_send_email = apply_filters(
1082 'srfm_should_send_email',
1083 true,
1084 $notification_id,
1085 $id,
1086 $form_data,
1087 );
1088
1089 if ( ! wp_validate_boolean( $should_send_email ) ) {
1090 continue;
1091 }
1092
1093 /**
1094 * Temporary override the content type for wp_mail.
1095 * This helps us from breaking of content type from other plugins.
1096 *
1097 * @since 1.2.2
1098 */
1099 add_filter(
1100 'wp_mail_content_type',
1101 static function() {
1102 return 'text/html'; // We need "text/html" content type to render our emails.
1103 },
1104 99
1105 );
1106
1107 /**
1108 * Start sending email.
1109 * Wrapping it in the buffer because when some plugin such as zoho mail, overrides the wp_mail
1110 * function and any exception is thrown ( Or printed ) from that plugin side, it affects the JSON response.
1111 * So, to make sure such exceptions doesn't affect our JSON response, we are wrapping it inside buffer.
1112 *
1113 * Try-Catch does not work because the notice or errors might be echoed by other plugins rather than thrown as an exception.
1114 *
1115 * @since 1.2.2
1116 */
1117 $sent = false;
1118 ob_start();
1119 $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1120 if ( ! $sent ) {
1121 // Fallback to default PHP mail if for some reasons wp_mail fails.
1122 $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1123
1124 if ( $sent ) {
1125 // Accepted by the local MTA, not delivered. Good
1126 // enough to avoid recording a fault, not good
1127 // enough to retire one.
1128 $used_mail_fallback = true;
1129 }
1130 }
1131 $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
1132
1133 if ( true !== $sent ) {
1134 $notification_failed = true;
1135 }
1136
1137 if ( is_int( $log_key ) ) {
1138 if ( true === $sent ) {
1139 $entries_db_instance->update_log(
1140 $log_key,
1141 null,
1142 [
1143 /* translators: Here, %s is the comma separated emails list. */
1144 sprintf( __( 'Email notification recipient: %s', 'sureforms' ), esc_html( $parsed['to'] ) ),
1145 ]
1146 );
1147 } else {
1148 $reason = ! empty( $email_report )
1149 ? esc_html( $email_report )
1150 : ( ! Helper::is_any_smtp_plugin_active()
1151 ? esc_html__( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' )
1152 : esc_html__( 'Email sending failed for an unknown reason.', 'sureforms' )
1153 );
1154
1155 $entries_db_instance->update_log(
1156 $log_key,
1157 null,
1158 [
1159 sprintf(
1160 /* translators: Here, %1$s is the comma separated emails list and %2$s is error report ( if any ). */
1161 __(
1162 'Email server was unable to send the email notification. Recipient: %1$s. Reason: %2$s',
1163 'sureforms'
1164 ),
1165 esc_html( $parsed['to'] ),
1166 $reason
1167 ),
1168 ]
1169 );
1170
1171 // Also record it in the debug log. The submission itself
1172 // succeeded, so the visitor saw nothing wrong and nobody
1173 // looks at the entry's own log until a ticket is already
1174 // open. The recipient address is not included -- the log
1175 // is downloadable and must not carry personal data.
1176 Client_Logger::append(
1177 Client_Logger::sanitize_entry(
1178 [
1179 'type' => 'message',
1180 'form_id' => intval( $id ),
1181 'form_title' => Helper::get_string_value( get_the_title( intval( $id ) ) ),
1182 'message' => 'Email notification failed to send. ' . $reason,
1183 ]
1184 )
1185 );
1186
1187 // Its own category: the entry saved, so this is not a
1188 // submission failure. The site owner is simply not being
1189 // told about entries they did receive.
1190 Client_Logger::record_failure(
1191 'notification',
1192 intval( $id ),
1193 Helper::get_string_value( get_the_title( intval( $id ) ) )
1194 );
1195 }
1196 }
1197
1198 // Trigger an action after the email is sent, allowing additional processing or logging.
1199 do_action(
1200 'srfm_after_email_send',
1201 $parsed,
1202 $submission_data,
1203 $item,
1204 $form_data
1205 );
1206
1207 $is_mail_sent = $sent;
1208 $emails[] = $parsed['to'];
1209 }
1210 }
1211 }
1212
1213 if ( empty( $emails ) ) {
1214 $entries_db_instance->reset_logs();
1215 $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) );
1216 }
1217
1218 // The notification fault clears when notifications work again. Nothing
1219 // else retired it: Client_Logger::clear_category() had a single caller
1220 // hardcoded to 'submission', and the notice is deliberately not
1221 // dismissible, so a site that had fixed its SMTP kept an undismissable
1222 // banner on every admin page until somebody opened a support ticket.
1223 // Held until the loop is done because one recipient succeeding while
1224 // another fails is still a failure.
1225 //
1226 // Scoped to the form the fault was recorded against. send_email() runs
1227 // on the public submit path and the counter is per category, not per
1228 // form, so without this an anonymous submission of a working form
1229 // wipes a different form's standing fault -- once per admin page load,
1230 // by anyone. The notice names a form, so the granularity is visible
1231 // now that this clears as well as records.
1232 //
1233 // wp_mail() only. The mail() fallback above returns true when the local
1234 // MTA merely accepts a message it will later bounce, which is the
1235 // broken configuration rather than the fixed one.
1236 //
1237 // is_int( $log_key ) mirrors the recording guard: record_failure() sits
1238 // inside it, so without it an install where add_log() returns a
1239 // non-int would never record a notification fault but would still
1240 // clear one.
1241 $open_failures = Client_Logger::get_failures();
1242
1243 if ( ! empty( $emails ) && ! $notification_failed && is_int( $log_key )
1244 && ! $used_mail_fallback
1245 && intval( $id ) === Helper::get_integer_value( $open_failures['notification']['form_id'] ?? 0 ) ) {
1246 Client_Logger::clear_category( 'notification' );
1247 }
1248 }
1249
1250 return [
1251 'success' => $is_mail_sent,
1252 'emails' => $emails,
1253 ];
1254 }
1255
1256 /**
1257 * Validate unique field values for a specific form via AJAX.
1258 *
1259 * Checks submitted field values against existing entries to determine
1260 * if duplicates exist. Rate-limited to prevent data enumeration.
1261 *
1262 * @since 0.0.1
1263 * @since 2.7.0 Added rate limiting, form validation, and optimized query.
1264 * @return void
1265 */
1266 public function field_unique_validation() {
1267 $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
1268 $form_id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1269
1270 if ( ! Submit_Token::verify( $token, $form_id ) ) {
1271 wp_send_json_error( [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ] );
1272 }
1273
1274 if ( ! $form_id ) {
1275 wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] );
1276 }
1277
1278 // Validate the form exists and is published to prevent cross-form probing.
1279 if ( 'publish' !== get_post_status( $form_id ) || 'sureforms_form' !== get_post_type( $form_id ) ) {
1280 wp_send_json_error( [ 'error' => __( 'Invalid form.', 'sureforms' ) ] );
1281 }
1282
1283 // Rate limit: 10 requests per minute per IP per form.
1284 if ( $this->is_unique_validation_rate_limited( $form_id ) ) {
1285 wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 );
1286 }
1287
1288 // SECURITY INVARIANT — only the fields the form itself marks unique may be
1289 // probed through this unauthenticated handler. The allowlist is what keeps the
1290 // lookup scoped to values a site owner opted into checking, rather than to
1291 // stored submission data generally. A form with no unique fields therefore
1292 // matches nothing and always answers with an empty set.
1293 $unique_block_ids = $this->get_unique_field_block_ids( $form_id );
1294
1295 // Extract and validate field values from POST data.
1296 $skip_keys = [ 'action', 'token', 'id' ];
1297 $duplicates = [];
1298
1299 foreach ( $_POST as $raw_key => $raw_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1300 if ( in_array( $raw_key, $skip_keys, true ) ) {
1301 continue;
1302 }
1303
1304 $field_key = str_replace( '_', ' ', sanitize_text_field( $raw_key ) );
1305 $value = sanitize_text_field( wp_unslash( $raw_value ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1306
1307 // Only process SureForms field keys (they contain -lbl- in the name).
1308 if ( false === strpos( $field_key, '-lbl-' ) ) {
1309 continue;
1310 }
1311
1312 if ( '' === $value ) {
1313 continue;
1314 }
1315
1316 // The key must resolve to a block this form configured as unique.
1317 $block_id = Helper::get_block_id_from_key( $field_key );
1318
1319 if ( '' === $block_id || ! isset( $unique_block_ids[ $block_id ] ) ) {
1320 continue;
1321 }
1322
1323 // Single optimized query per field instead of loading all entries.
1324 if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) {
1325 $duplicates[] = [ $field_key => 'not unique' ];
1326 }
1327 }
1328
1329 wp_send_json( [ 'data' => $duplicates ] );
1330 }
1331
1332 /**
1333 * Function to save allowed block data.
1334 *
1335 * @since 0.0.1
1336 * @return void
1337 */
1338 public function srfm_global_update_allowed_block() {
1339 if ( ! Helper::current_user_can() ) {
1340 wp_send_json_error();
1341 }
1342
1343 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1344 wp_send_json_error();
1345 }
1346
1347 if ( ! empty( $_POST['defaultAllowedQuickSidebarBlocks'] ) ) {
1348 $srfm_default_allowed_quick_sidebar_blocks = json_decode( sanitize_text_field( wp_unslash( $_POST['defaultAllowedQuickSidebarBlocks'] ) ), true );
1349 Helper::update_admin_settings_option( 'srfm_quick_sidebar_allowed_blocks', $srfm_default_allowed_quick_sidebar_blocks );
1350 wp_send_json_success();
1351 }
1352 wp_send_json_error();
1353 }
1354
1355 /**
1356 * Function to save enable/disable data.
1357 *
1358 * @since 0.0.1
1359 * @return void
1360 */
1361 public function srfm_global_sidebar_enabled() {
1362 if ( ! Helper::current_user_can() ) {
1363 wp_send_json_error();
1364 }
1365
1366 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
1367 wp_send_json_error();
1368 }
1369
1370 if ( ! empty( $_POST['enableQuickActionSidebar'] ) ) {
1371 $srfm_enable_quick_action_sidebar = ( 'enabled' === $_POST['enableQuickActionSidebar'] ? 'enabled' : 'disabled' );
1372 Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar );
1373 wp_send_json_success();
1374 }
1375 wp_send_json_error();
1376 }
1377
1378 /**
1379 * Send error response for reCAPTCHA validation failure.
1380 *
1381 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1382 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1383 * @since 1.7.0
1384 * @return void
1385 */
1386 public function recaptcha_error_response( $type, $api_response ) {
1387 $error_message = $this->recaptcha_error_message( $type, $api_response );
1388 $response = array_merge(
1389 [
1390 'api_response' => $api_response,
1391 ],
1392 $error_message
1393 );
1394
1395 wp_send_json_error( $response );
1396 }
1397
1398 /**
1399 * Get the error message for a CAPTCHA validation failure based on the service type and API response.
1400 *
1401 * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1402 * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1403 * @since 1.7.0
1404 * @return array<string,string> An associative array containing the error message and a detailed message.
1405 */
1406 public function recaptcha_error_message( $type, $api_response ) {
1407
1408 if ( empty( $api_response['error-codes'] ) || ! is_array( $api_response['error-codes'] ) ) {
1409 return [
1410 'detail_message' => __( 'Captcha validation failed. No error code provided.', 'sureforms' ),
1411 'message' => __( 'Captcha validation failed.', 'sureforms' ),
1412 ];
1413 }
1414
1415 /**
1416 * Note: The error codes are not translated because these messages are intended for debugging purposes.
1417 * Translating them would make debugging difficult. These error messages are primarily for developers or administrators.
1418 * A generic message will be displayed to the user, while detailed error information will be logged or shown in the console.
1419 */
1420
1421 // Google reCAPTCHA error codes.
1422 // Reference: (https://developers.google.com/recaptcha/docs/verify#error-code-reference).
1423 $google_recaptcha_error = [
1424 'missing-input-secret' => 'The secret parameter is missing.',
1425 'invalid-input-secret' => 'The secret parameter is invalid or malformed.',
1426 'missing-input-response' => 'The response parameter is missing.',
1427 'invalid-input-response' => 'The response parameter is invalid or malformed.',
1428 'bad-request' => 'The request is invalid or malformed.',
1429 'timeout-or-duplicate' => 'The response is no longer valid: either is too old or has been used previously.',
1430 ];
1431
1432 // hCaptcha error codes.
1433 // Reference: (https://docs.hcaptcha.com/#siteverify-error-codes).
1434 $hcaptcha_errors = [
1435 'missing-input-secret' => 'Your secret key is missing.',
1436 'invalid-input-secret' => 'Your secret key is invalid or malformed.',
1437 'missing-input-response' => 'The response parameter (verification token) is missing.',
1438 'invalid-input-response' => 'The response parameter (verification token) is invalid or malformed.',
1439 'expired-input-response' => 'The response parameter (verification token) is expired. (120s default)',
1440 'already-seen-response' => 'The response parameter (verification token) was already verified once.',
1441 'bad-request' => 'The request is invalid or malformed.',
1442 'missing-remoteip' => 'The remoteip parameter is missing.',
1443 'invalid-remoteip' => 'The remoteip parameter is not a valid IP address or blinded value.',
1444 'not-using-dummy-passcode' => 'You have used a testing sitekey but have not used its matching secret.',
1445 'sitekey-secret-mismatch' => 'The sitekey is not registered with the provided secret.',
1446 ];
1447
1448 // Cloudflare Turnstile error codes.
1449 // Reference: (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).
1450 $cf_turnstile_errors = [
1451 'missing-input-secret' => 'The secret parameter was not passed.',
1452 'invalid-input-secret' => 'The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.',
1453 'missing-input-response' => 'The response parameter (token) was not passed.',
1454 '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.',
1455 'bad-request' => 'The request was rejected because it was malformed.',
1456 '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.',
1457 'internal-error' => 'An internal error happened while validating the response. The request can be retried.',
1458 ];
1459
1460 $error_code = $api_response['error-codes'][0] ?? 'no-error-code';
1461
1462 $captcha_title = '';
1463 $captcha_message = '';
1464 switch ( $type ) {
1465 case 'g-recaptcha':
1466 $captcha_title = __( 'Google reCAPTCHA', 'sureforms' );
1467 $captcha_message = $google_recaptcha_error[ $error_code ];
1468 break;
1469 case 'hcaptcha':
1470 $captcha_title = __( 'hCaptcha', 'sureforms' );
1471 $captcha_message = $hcaptcha_errors[ $error_code ];
1472 break;
1473 case 'cf-turnstile':
1474 $captcha_title = __( 'Cloudflare Turnstile', 'sureforms' );
1475 $captcha_message = $cf_turnstile_errors[ $error_code ];
1476 break;
1477 default:
1478 $captcha_title = __( 'Unknown Captcha', 'sureforms' );
1479 $captcha_message = __( 'Invalid captcha type.', 'sureforms' );
1480 break;
1481 }
1482
1483 $detail_message = sprintf(
1484 '%s: %s <br> Error Code: %s',
1485 $captcha_title,
1486 $captcha_message ?? 'Unknown error occurred.',
1487 $error_code
1488 );
1489
1490 $message = sprintf(
1491 /* translators: %s is the captcha title. */
1492 __( '%s verification failed. Please contact your site administrator.', 'sureforms' ),
1493 $captcha_title
1494 );
1495
1496 return [
1497 'log_message' => $detail_message, // This variable is used for logging purposes, such as displaying detailed error information in the console on the front end.
1498 'message' => $message,
1499 ];
1500 }
1501
1502 /**
1503 * Sanitise and validate a Referer into a storable submission URL.
1504 *
1505 * The value is rebuilt from parsed components so a non-browser client cannot
1506 * inject bits a real browser would never send (userinfo, fragment) or mismatch
1507 * the legitimate origin's port. Anything that is not a same-origin http(s) URL,
1508 * or is longer than 2048 chars, is rejected and returns an empty string.
1509 *
1510 * Uses esc_url_raw() rather than sanitize_text_field(): the latter strips
1511 * percent-encoded octets (`%E0%A4...`), which mangles the URLs of translated
1512 * pages whose slugs contain non-ASCII characters (e.g. WPML Hindi/Arabic
1513 * permalinks) down to bare hyphens. esc_url_raw() preserves the percent-encoding
1514 * so the recorded submission URL stays accurate.
1515 *
1516 * @param string $referer Raw (unslashed) Referer header value.
1517 * @since 2.11.0
1518 * @return string Same-origin http(s) URL, or empty string when invalid.
1519 */
1520 protected function normalize_submission_url( string $referer ): string {
1521 $referer = esc_url_raw( $referer );
1522
1523 if ( '' === $referer || strlen( $referer ) > 2048 ) {
1524 return '';
1525 }
1526
1527 $parts = wp_parse_url( $referer );
1528 $home_parts = wp_parse_url( home_url() );
1529
1530 if (
1531 ! is_array( $parts )
1532 || ! is_array( $home_parts )
1533 || ! isset( $parts['scheme'], $parts['host'], $home_parts['host'] )
1534 || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true )
1535 || 0 !== strcasecmp( (string) $parts['host'], (string) $home_parts['host'] )
1536 || ( $parts['port'] ?? null ) !== ( $home_parts['port'] ?? null )
1537 ) {
1538 return '';
1539 }
1540
1541 $clean = $parts['scheme'] . '://' . $parts['host']
1542 . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' )
1543 . ( $parts['path'] ?? '' )
1544 . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
1545
1546 return esc_url_raw( $clean, [ 'http', 'https' ] );
1547 }
1548
1549 /**
1550 * Check whether the given language code is known to the active multilingual
1551 * provider (i.e. in its active-languages set or matches the default language).
1552 *
1553 * Used to reject crafted srfm-form-language hidden-input values that pass
1554 * the BCP-47 shape regex but reference languages the site doesn't actually
1555 * support.
1556 *
1557 * @param string $language Language code to check (e.g. 'hi', 'de-AT').
1558 * @since 2.11.0
1559 * @return bool True when the code is known, false otherwise.
1560 */
1561 protected function is_known_language( string $language ): bool {
1562 if ( '' === $language ) {
1563 return false;
1564 }
1565
1566 $provider = Multilingual_Manager::get_instance()->provider();
1567
1568 // When no provider is active there's no authoritative set to check
1569 // against. Accept whatever the visitor sent (shape-validated) so the
1570 // column still reflects the visitor's intent on non-WPML sites.
1571 if ( ! $provider->is_active() ) {
1572 return true;
1573 }
1574
1575 // Default language is always considered known.
1576 if ( $language === $provider->default_language() ) {
1577 return true;
1578 }
1579
1580 // Use WPML's filter when available — works regardless of which
1581 // multilingual plugin is the active provider, as Polylang implements
1582 // the same filter for compatibility.
1583 $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.
1584 if ( is_array( $active ) && ! empty( $active ) ) {
1585 return array_key_exists( $language, $active );
1586 }
1587
1588 // A provider IS active but its language list is unavailable. Rather than
1589 // fail open and trust an arbitrary client-supplied code, accept it only when
1590 // it matches the server-resolved current language. The caller already
1591 // defaults $entry_language to current_language(), so this keeps mis-tagging
1592 // to the server's own determination instead of the (cacheable) client value.
1593 return $language === $provider->current_language();
1594 }
1595
1596 /**
1597 * Collect the block IDs of the fields a form configures as unique.
1598 *
1599 * Derived from the stored form, never from the request — the whole point is that
1600 * the client cannot nominate which fields are probeable. The frontend already
1601 * sends only inputs rendered with data-unique="true", which comes from the same
1602 * isUnique attribute, so this is the server-side mirror of what the client does.
1603 *
1604 * @param int $form_id Form ID.
1605 *
1606 * @since 2.12.3
1607 * @return array<string,true> Unique field block IDs, keyed by block ID.
1608 */
1609 private function get_unique_field_block_ids( $form_id ) {
1610 $form = get_post( $form_id );
1611
1612 if ( ! $form instanceof \WP_Post || '' === $form->post_content ) {
1613 return [];
1614 }
1615
1616 $visited_refs = [];
1617 $block_ids = $this->collect_unique_field_block_ids( parse_blocks( $form->post_content ), $visited_refs );
1618
1619 /**
1620 * Filters the block IDs treated as unique fields for the AJAX uniqueness check.
1621 *
1622 * Lets add-ons whose fields a static parse of the form cannot see contribute
1623 * their own unique fields.
1624 *
1625 * @since 2.12.3
1626 *
1627 * @param array<string,true> $block_ids Unique field block IDs, keyed by block ID.
1628 * A plain list of IDs is accepted too and is
1629 * normalised to this shape.
1630 * @param int $form_id Form ID.
1631 */
1632 $filtered = apply_filters( 'srfm_unique_field_block_ids', $block_ids, $form_id );
1633
1634 // Normalise rather than trust: the lookup is isset( $set[ $block_id ] ), so an
1635 // add-on returning a plain list would silently disable uniqueness for the form
1636 // instead of adding to it. A non-array return keeps the derived set.
1637 return is_array( $filtered ) ? self::normalize_block_id_set( $filtered ) : $block_ids;
1638 }
1639
1640 /**
1641 * Normalise a block-ID collection to a block ID => true map.
1642 *
1643 * Accepts both the documented map shape and a plain list of IDs.
1644 *
1645 * @param array<mixed> $block_ids Block IDs as a map or a list.
1646 *
1647 * @since 2.12.3
1648 * @return array<string,true> Block IDs keyed by block ID.
1649 */
1650 private static function normalize_block_id_set( $block_ids ) {
1651 $normalized = [];
1652
1653 foreach ( $block_ids as $key => $value ) {
1654 // List entry: the ID is the value. Map entry: the ID is the key.
1655 $block_id = is_int( $key ) ? $value : $key;
1656
1657 if ( is_string( $block_id ) && '' !== $block_id ) {
1658 $normalized[ $block_id ] = true;
1659 }
1660 }
1661
1662 return $normalized;
1663 }
1664
1665 /**
1666 * Recursively collect block IDs of blocks whose isUnique attribute is enabled.
1667 *
1668 * Recurses into innerBlocks (repeater/container children) and expands
1669 * reusable/synced patterns, mirroring Form_Styling::collect_form_block_ids().
1670 *
1671 * Note: parse_blocks() does NOT apply block.json defaults, unlike the render path.
1672 * Every field block therefore has to keep isUnique defaulting to false — a block
1673 * that defaults it to true would be serialised without the attribute and would be
1674 * missed here while still rendering data-unique="true".
1675 *
1676 * @param array<mixed> $blocks Parsed blocks from parse_blocks().
1677 * @param array<int, true> $visited_refs Reusable-block post IDs already expanded,
1678 * keyed by ID — guards against reference cycles.
1679 *
1680 * @since 2.12.3
1681 * @return array<string,true> Unique field block IDs, keyed by block ID.
1682 */
1683 private function collect_unique_field_block_ids( $blocks, &$visited_refs = [] ) {
1684 $block_ids = [];
1685
1686 foreach ( $blocks as $block ) {
1687 if ( ! is_array( $block ) ) {
1688 continue;
1689 }
1690
1691 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
1692
1693 if ( ! empty( $attrs['isUnique'] ) && ! empty( $attrs['block_id'] ) && is_scalar( $attrs['block_id'] ) ) {
1694 $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true;
1695 }
1696
1697 // Reusable/synced pattern: expand the referenced wp_block post so a field
1698 // living inside a pattern is seen like an inline block.
1699 if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
1700 $ref = absint( $attrs['ref'] );
1701
1702 if ( $ref && ! isset( $visited_refs[ $ref ] ) ) {
1703 $visited_refs[ $ref ] = true;
1704 $ref_post = get_post( $ref );
1705
1706 if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && 'publish' === $ref_post->post_status && '' !== $ref_post->post_content ) {
1707 $block_ids += $this->collect_unique_field_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs );
1708 }
1709 }
1710 }
1711
1712 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1713 $block_ids += $this->collect_unique_field_block_ids( $block['innerBlocks'], $visited_refs );
1714 }
1715 }
1716
1717 return $block_ids;
1718 }
1719
1720 /**
1721 * Check if the current request is rate-limited for unique validation.
1722 *
1723 * Uses transients keyed by IP + form ID to throttle requests.
1724 * Allows 10 requests per 60-second window per IP per form.
1725 *
1726 * @param int $form_id The form ID being validated.
1727 * @since 2.7.0
1728 * @return bool True if rate-limited (should block), false if allowed.
1729 */
1730 private function is_unique_validation_rate_limited( $form_id ) {
1731 return $this->is_rate_limited( 'srfm_uv_', $form_id );
1732 }
1733
1734 /**
1735 * Throttle a public endpoint to 10 requests per minute per IP per form.
1736 *
1737 * Shared by the uniqueness check and the client log route rather than
1738 * duplicated, so a change to the window applies to both.
1739 *
1740 * @param string $prefix Transient key prefix, unique per endpoint.
1741 * @param int $form_id The form ID the request relates to.
1742 * @since 2.12.6
1743 * @return bool True if rate-limited (should block), false if allowed.
1744 */
1745 private function is_rate_limited( $prefix, $form_id ) {
1746 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
1747
1748 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1749 return true; // Fail closed if IP cannot be determined.
1750 }
1751
1752 $transient_key = $prefix . md5( $ip . '_' . $form_id );
1753 $attempts = get_transient( $transient_key );
1754
1755 if ( false === $attempts ) {
1756 set_transient( $transient_key, 1, MINUTE_IN_SECONDS );
1757 return false;
1758 }
1759
1760 $attempts_count = Helper::get_integer_value( $attempts );
1761
1762 if ( $attempts_count >= 10 ) {
1763 return true;
1764 }
1765
1766 set_transient( $transient_key, $attempts_count + 1, MINUTE_IN_SECONDS );
1767 return false;
1768 }
1769
1770 /**
1771 * Process and sanitize SureForms field data from submitted form data.
1772 *
1773 * @param array<mixed> $form_data Raw form data from submission.
1774 *
1775 * @since 1.11.0
1776 * @return array Processed and sanitized submission data.
1777 */
1778 private function process_form_fields( $form_data ) {
1779 $form_id = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? absint( $form_data['form-id'] ) : 0;
1780
1781 $submission_data = [];
1782
1783 $form_data_keys = array_keys( $form_data );
1784 $form_data_count = count( $form_data );
1785
1786 for ( $i = 0; $i < $form_data_count; $i++ ) {
1787 $key = strval( $form_data_keys[ $i ] );
1788
1789 /**
1790 * This will allow to pass only sureforms fields
1791 * checking -lbl- as thats mandatory for in key of sureforms fields.
1792 */
1793 if ( false === str_contains( $key, '-lbl-' ) ) {
1794 continue;
1795 }
1796
1797 $value = $form_data[ $key ];
1798
1799 $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
1800
1801 $field_block_name = Helper::get_block_name_from_field( $field_name );
1802
1803 /**
1804 * Filters the field value during form submission processing.
1805 *
1806 * This filter allows the Pro plugin to process and modify field values before they are saved.
1807 * The Pro plugin can implement custom sanitization, validation and escaping logic for its
1808 * specialized field types. When this filter is used by Pro, the core plugin will skip its
1809 * default validation.
1810 *
1811 * @since 1.11.0
1812 *
1813 * @param mixed $value The raw field value from form submission.
1814 * @param array $field_data Field information array containing:
1815 * - 'field_name': The field name/key
1816 * - 'field_block_name': The block type identifier
1817 * @return array {
1818 * Processed field value data
1819 *
1820 * @type bool $is_processed Whether the value was processed by Pro plugin
1821 * @type mixed $value The processed and sanitized field value
1822 * }
1823 */
1824 $process_field_value = apply_filters(
1825 'srfm_process_field_value',
1826 $value,
1827 [
1828 'field_name' => $field_name,
1829 'field_block_name' => $field_block_name,
1830 ]
1831 );
1832
1833 if ( is_array( $process_field_value ) && ! empty( $process_field_value['is_processed'] ) && ! empty( $process_field_value['value'] ) ) {
1834 $submission_data[ $field_name ] = $process_field_value['value'];
1835 continue;
1836 }
1837
1838 /**
1839 * Need to remove this refactor array value handling.
1840 *
1841 * The current array-based value handling needs to be replaced with:
1842 * 1. Block-specific value processing based on block type.
1843 * 2. Move premium features to pro version.
1844 * 3. Implement value processing through filters for extensibility.
1845 *
1846 * This will improve code organization and maintainability while properly
1847 * separating free/pro functionality.
1848 */
1849
1850 // If the field is an array, encode the values. This is to add support for multi-upload field.
1851 if ( is_array( $value ) ) {
1852 $submission_data[ $field_name ] =
1853 array_map(
1854 static function ( $val ) {
1855 return rawurlencode( $val );
1856 },
1857 $value
1858 );
1859 } else {
1860 $submission_data[ $field_name ] = is_string( $value ) ? htmlspecialchars( $value ) : $value;
1861 }
1862 }
1863
1864 /**
1865 * Filters the submission data before preparing it for storage.
1866 *
1867 * The second parameter is a context array containing additional metadata
1868 * about the submission. This array is extensible — new keys may be added
1869 * in future versions without changing the filter signature.
1870 *
1871 * @since 2.6.0
1872 *
1873 * @param array<string,mixed> $submission_data Processed form submission data.
1874 * @param array<string,mixed> $context {
1875 * Additional context for the submission.
1876 *
1877 * @type int $form_id The ID of the form being submitted.
1878 * }
1879 */
1880 return apply_filters(
1881 'srfm_before_prepare_submission_data',
1882 $submission_data,
1883 [
1884 'form_id' => $form_id,
1885 ]
1886 );
1887 }
1888
1889 /**
1890 * Add From email and name in the header.
1891 *
1892 * @param array<mixed> $submission_data Submission data.
1893 * @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'.
1894 * @param Smart_Tags $smart_tags Smart Tags instance.
1895 * @since 1.6.1
1896 * @return string The formatted "From" email header.
1897 */
1898 private static function add_from_data_in_header( $submission_data, $item, $smart_tags ) {
1899 $from_name = is_array( $item ) && ! empty( $item['from_name'] ) ? sanitize_text_field( Helper::get_string_value( $item['from_name'] ) ) : '{site_title}';
1900 $from_email = is_array( $item ) && ! empty( $item['from_email'] ) ? Helper::get_string_value( $item['from_email'] ) : '{admin_email}';
1901
1902 // Check if the email contains smart tags. If not, validate the email.
1903 $is_valid_email = true;
1904 if ( ! str_contains( $from_email, '{' ) && ! str_contains( $from_email, '}' ) ) {
1905 $is_valid_email = filter_var( $from_email, FILTER_VALIDATE_EMAIL );
1906 }
1907 // if the email is not valid, set it to the admin email.
1908 if ( ! $is_valid_email ) {
1909 $from_email = Helper::get_string_value( get_option( 'admin_email' ) );
1910 }
1911
1912 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";
1913 }
1914 }
1915