PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 1.2.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v1.2.0
2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 All 96 releases
← All changes | inc/form-submit.php +207 -1256 trunk1.2.0 View file →
@@ -7,9 +7,8 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 -use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager;
12 11 use SRFM\Inc\Database\Tables\Entries;
13 12 use SRFM\Inc\Email\Email_Template;
14 13 use SRFM\Inc\Lib\Browser\Browser;
15 14 use SRFM\Inc\Traits\Get_Instance;
@@ -39,16 +38,8 @@
39 38 */
40 39 protected $namespace = 'sureforms/v1';
41 40
42 41 /**
43 - * Addresses.
44 - *
45 - * @var string
46 - * @since 1.6.1
47 - */
48 - private $addresses = '';
49 -
50 - /**
51 42 * Constructor
52 43 *
53 44 * @since 0.0.1
54 45 */
@@ -53,25 +44,8 @@
53 44 * @since 0.0.1
54 45 */
55 46 public function __construct() {
56 47 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 48 add_action( 'wp_ajax_validation_ajax_action', [ $this, 'field_unique_validation' ] );
75 49 add_action( 'wp_ajax_nopriv_validation_ajax_action', [ $this, 'field_unique_validation' ] );
76 50 // for quick action bar.
77 51 add_action( 'wp_ajax_srfm_global_update_allowed_block', [ $this, 'srfm_global_update_allowed_block' ] );
@@ -90,170 +64,14 @@
90 64 '/submit-form',
91 65 [
92 66 'methods' => WP_REST_Server::EDITABLE,
93 67 'callback' => [ $this, 'handle_form_submission' ],
94 - 'permission_callback' => [ $this, 'submit_form_permissions_check' ],
68 + 'permission_callback' => '__return_true',
95 69 ]
96 70 );
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 71 }
108 72
109 73 /**
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 74 * Check whether a given request has permission access route.
257 75 *
258 76 * @since 0.0.1
259 77 * @return WP_Error|bool
@@ -258,10 +76,10 @@
258 76 * @since 0.0.1
259 77 * @return WP_Error|bool
260 78 */
261 79 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() ] );
80 + if ( ! current_user_can( 'manage_options' ) ) {
81 + return new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot access this route', 'sureforms' ), [ 'status' => rest_authorization_required_code() ] );
264 82 }
265 83 return true;
266 84 }
267 85
@@ -373,83 +191,72 @@
373 191 * @since 0.0.1
374 192 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
375 193 */
376 194 public function handle_form_submission( $request ) {
377 - $form_data = Helper::sanitize_by_field_type( $request->get_params() );
378 195
379 - if ( empty( $form_data ) || ! is_array( $form_data ) ) {
380 - wp_send_json_error( [ 'message' => __( 'Form data is not found.', 'sureforms' ) ] );
381 - }
196 + $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
382 197
383 - if ( empty( $form_data['form-id'] ) ) {
198 + if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
384 199 wp_send_json_error(
385 200 [
386 - 'message' => __( 'Form ID is missing.', 'sureforms' ),
387 - 'position' => 'header',
201 + 'data' => __( 'Nonce verification failed.', 'sureforms' ),
202 + 'status' => false,
388 203 ]
389 204 );
390 205 }
391 206
392 - $current_form_id = $form_data['form-id'];
207 + $form_data = Helper::sanitize_by_field_type( $request->get_params() );
393 208
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 - );
209 + if ( empty( $form_data ) || ! is_array( $form_data ) ) {
210 + wp_send_json_error( __( 'Form data is not found.', 'sureforms' ) );
413 211 }
414 212
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 - }
213 + if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] && ! empty( $_FILES ) ) {
214 + add_filter( 'upload_dir', [ $this, 'change_upload_dir' ] );
422 215
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 - }
216 + foreach ( $_FILES as $field => $file ) {
217 + if ( is_array( $file['name'] ) ) {
218 + foreach ( $file['name'] as $key => $filename ) {
219 + $temp_path = $file['tmp_name'][ $key ];
220 + $file_size = $file['size'][ $key ];
221 + $file_type = $file['type'][ $key ];
222 + $file_error = $file['error'][ $key ];
432 223
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 );
224 + if ( ! $filename && ! $temp_path && ! $file_size && ! $file_type ) {
225 + $form_data[ $field ][] = '';
226 + continue;
227 + }
437 228
438 - $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id );
229 + $uploaded_file = [
230 + 'name' => $filename,
231 + 'type' => $file_type,
232 + 'tmp_name' => $temp_path,
233 + 'error' => $file_error,
234 + 'size' => $file_size,
235 + ];
439 236
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 );
237 + $upload_overrides = [
238 + 'test_form' => false,
239 + ];
240 + $move_file = wp_handle_upload( $uploaded_file, $upload_overrides );
241 + remove_filter( 'upload_dir', [ $this, 'change_upload_dir' ] );
443 242
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 - );
243 + if ( $move_file && ! isset( $move_file['error'] ) ) {
244 + $form_data[ $field ][] = $move_file['url'];
245 + } else {
246 + wp_send_json_error( __( 'File is not uploaded', 'sureforms' ) );
247 + }
248 + }
249 + } else {
250 + $form_data[ $field ][] = '';
251 + }
252 + }
450 253 }
451 254
255 + if ( ! $form_data['form-id'] ) {
256 + wp_send_json_error( __( 'Form Id is missing.', 'sureforms' ) );
257 + }
258 + $current_form_id = $form_data['form-id'];
452 259 $security_type = Helper::get_meta_value( Helper::get_integer_value( $current_form_id ), '_srfm_captcha_security_type' );
453 260 $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 261
455 262 if ( 'none' !== $security_type ) {
@@ -479,9 +286,9 @@
479 286
480 287 if ( 'cf-turnstile' === $security_type ) {
481 288 // Turnstile validation.
482 289 $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'] : '';
290 + $cf_response = ! empty( $form_data['cf-turnstile-response'] ) ? $form_data['cf-turnstile-response'] : false;
484 291
485 292 // if gdpr is enabled then set remote ip to empty.
486 293 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
487 294 $gdpr = false;
@@ -499,15 +306,16 @@
499 306 $turnstile_validation_result = self::validate_turnstile_token( $srfm_cf_turnstile_secret_key, $cf_response, $remote_ip );
500 307
501 308 // If the cloudflare validation fails, return an error.
502 309 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 );
310 + $error_message = $turnstile_validation_result['error'] ?? __( 'Cloudflare Turnstile validation failed.', 'sureforms' );
311 + return new \WP_Error( 'cf_turnstile_error', $error_message, [ 'status' => 403 ] );
504 312 }
505 313 }
506 314
507 315 if ( 'hcaptcha' === $security_type ) {
508 316 $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'] : '';
317 + $hcaptcha_response = ! empty( $form_data['h-captcha-response'] ) ? $form_data['h-captcha-response'] : false;
510 318
511 319 // if gdpr is enabled then set remote ip to empty.
512 320 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
513 321 $gdpr = false;
@@ -524,15 +332,16 @@
524 332 $hcaptcha_validation_result = self::validate_hcaptcha_token( $srfm_hcaptcha_secret_key, $hcaptcha_response, $remote_ip );
525 333
526 334 // If the hcaptcha validation fails, return an error.
527 335 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 );
336 + $error_message = $hcaptcha_validation_result['error'] ?? __( 'hCaptcha validation failed.', 'sureforms' );
337 + return new \WP_Error( 'hcaptcha_error', $error_message, [ 'status' => 403 ] );
529 338 }
530 339 }
531 340
532 341 if ( isset( $form_data['srfm-honeypot-field'] ) && empty( $form_data['srfm-honeypot-field'] ) ) {
533 342 if ( ! empty( $google_captcha_secret_key ) ) {
534 - if ( ! empty( $form_data['form-id'] ) ) {
343 + if ( isset( $form_data['sureforms_form_submit'] ) ) {
535 344 $secret_key = $google_captcha_secret_key;
536 345 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
537 346 $captcha_response = $form_data['g-recaptcha-response'];
538 347 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
@@ -547,37 +356,22 @@
547 356 }
548 357 $sureforms_captcha_data = $data;
549 358
550 359 } else {
551 - wp_send_json_error(
552 - [
553 - 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
554 - ]
555 - );
360 + return new \WP_Error( 'recaptcha_error', __( 'reCAPTCHA error.', 'sureforms' ), [ 'status' => 403 ] );
556 361 }
557 362 if ( isset( $sureforms_captcha_data['success'] ) && true === $sureforms_captcha_data['success'] ) {
558 363 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
559 364 }
365 + return new \WP_Error( 'recaptcha_error', __( 'reCAPTCHA error.', 'sureforms' ), [ 'status' => 403 ] );
560 366
561 - $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
562 367 }
368 + return rest_ensure_response( $this->handle_form_entry( $form_data ) );
563 369
564 - return rest_ensure_response( $this->handle_form_entry( $form_data ) );
565 370 }
566 -
567 371 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 372 if ( ! empty( $google_captcha_secret_key ) ) {
579 - if ( ! empty( $form_data['form-id'] ) ) {
373 + if ( isset( $form_data['sureforms_form_submit'] ) ) {
580 374 $secret_key = $google_captcha_secret_key;
581 375 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
582 376 $captcha_response = $form_data['g-recaptcha-response'];
583 377 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
@@ -592,29 +386,34 @@
592 386 }
593 387 $sureforms_captcha_data = $data;
594 388
595 389 } else {
596 - wp_send_json_error(
597 - [
598 - 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
599 - ]
600 - );
390 + return new \WP_Error( 'recaptcha_error', __( 'reCAPTCHA error.', 'sureforms' ), [ 'status' => 403 ] );
601 391 }
602 392 if ( true === $sureforms_captcha_data['success'] ) {
603 393 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
604 394 }
395 + return new \WP_Error( 'recaptcha_error', __( 'reCAPTCHA error.', 'sureforms' ), [ 'status' => 403 ] );
605 396
606 - $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
607 397 }
398 + return rest_ensure_response( $this->handle_form_entry( $form_data ) );
608 399
609 - return rest_ensure_response( $this->handle_form_entry( $form_data ) );
610 400 }
401 + return new \WP_Error( 'spam_detected', __( 'Spam Detected', 'sureforms' ), [ 'status' => 403 ] );
402 + }
611 403
612 - wp_send_json_error(
613 - [
614 - 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ),
615 - ]
616 - );
404 + /**
405 + * Change the upload directory
406 + *
407 + * @param array<mixed> $dirs upload directory.
408 + * @return array<mixed>
409 + * @since 0.0.1
410 + */
411 + public function change_upload_dir( $dirs ) {
412 + $dirs['subdir'] = '/sureforms';
413 + $dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
414 + $dirs['url'] = $dirs['baseurl'] . $dirs['subdir'];
415 + return $dirs;
617 416 }
618 417
619 418 /**
620 419 * Send Email and Create Entry.
@@ -623,25 +422,8 @@
623 422 * @since 0.0.1
624 423 * @return array<mixed> Array containing the response data.
625 424 */
626 425 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 426
645 427 $id = sanitize_text_field( $form_data['form-id'] );
646 428
647 429 // Get the compliance settings.
@@ -653,20 +435,42 @@
653 435 $gdpr = $compliance[0]['gdpr'] ?? '';
654 436 $do_not_store_entries = $compliance[0]['do_not_store_entries'] ?? '';
655 437 }
656 438
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 - }
439 + $submission_data = [];
664 440
665 - $form_data = apply_filters( 'srfm_before_fields_processing', $form_data );
441 + $form_data_keys = array_keys( $form_data );
442 + $form_data_count = count( $form_data );
666 443
667 - $submission_data = $this->process_form_fields( $form_data );
444 + for ( $i = 0; $i < $form_data_count; $i++ ) {
445 + $key = strval( $form_data_keys[ $i ] );
668 446
447 + /**
448 + * This will allow to pass only sureforms fields
449 + * checking -lbl- as thats mandatory for in key of sureforms fields.
450 + */
451 + if ( false === str_contains( $key, '-lbl-' ) ) {
452 + continue;
453 + }
454 +
455 + $value = $form_data[ $key ];
456 +
457 + $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
458 +
459 + // If the field is an array, encode the values. This is to add support for multi-upload field.
460 + if ( is_array( $value ) ) {
461 + $submission_data[ $field_name ] =
462 + array_map(
463 + static function ( $val ) {
464 + return rawurlencode( $val );
465 + },
466 + $value
467 + );
468 + } else {
469 + $submission_data[ $field_name ] = htmlspecialchars( $value );
470 + }
471 + }
472 +
669 473 $modified_message = $this->prepare_submission_data( $submission_data );
670 474
671 475 $form_before_submission_data = [
672 476 'form_id' => $id ? intval( $id ) : '',
@@ -677,19 +481,19 @@
677 481 * Fires before submission process starts.
678 482 */
679 483 do_action( 'srfm_before_submission', $form_before_submission_data );
680 484
681 - $name = sanitize_text_field( get_the_title( intval( $id ) ) );
682 - $emails = [];
485 + $name = sanitize_text_field( get_the_title( intval( $id ) ) );
486 + $send_email = $this->send_email( $id, $submission_data, $form_data );
487 + $emails = [];
683 488
489 + if ( $send_email ) {
490 + $emails = $send_email['emails'];
491 + }
492 +
684 493 // Check if GDPR is enabled and do not store entries is enabled.
685 494 // If so, send email and do not store entries.
686 495 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 496
693 497 $form_submit_response = [
694 498 'success' => true,
695 499 'form_id' => $id ? intval( $id ) : '',
@@ -722,14 +526,13 @@
722 526 }
723 527
724 528 $global_setting_options = get_option( 'srfm_general_settings_options' );
725 529
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 = '';
530 + // If GDPR is enabled, do not store IP, browser, and device info.
531 + // If not, store IP, browser, and device info.
532 + $user_ip = '';
533 + $browser_name = '';
534 + $device_name = '';
732 535 if ( ! $gdpr ) {
733 536 $srfm_ip_log = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_ip_log'] ) ? $global_setting_options['srfm_ip_log'] : '';
734 537
735 538 $user_ip = $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
@@ -735,14 +538,8 @@
735 538 $user_ip = $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
736 539 $browser = new Browser();
737 540 $browser_name = sanitize_text_field( $browser->getBrowser() );
738 541 $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 542 }
746 543
747 544 $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) );
748 545 $pattern = '/"label":"(.*?)"/';
@@ -747,106 +544,33 @@
747 544 $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) );
748 545 $pattern = '/"label":"(.*?)"/';
749 546 preg_match_all( $pattern, $form_markup, $matches );
750 547 $submission_info = [
751 - 'user_ip' => $user_ip,
752 - 'browser_name' => $browser_name,
753 - 'device_name' => $device_name,
754 - 'submission_url' => $submission_url,
548 + 'user_ip' => $user_ip,
549 + 'browser_name' => $browser_name,
550 + 'device_name' => $device_name,
755 551 ];
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 = [
552 + $entries_data = [
774 553 'form_id' => $id,
775 554 'form_data' => $submission_data,
776 555 'submission_info' => $submission_info,
777 - 'created_at' => current_time( 'mysql' ),
778 556 ];
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;
557 + if ( is_user_logged_in() ) {
558 + // If user is logged in then save their user id.
559 + $entries_data['user_id'] = get_current_user_id();
786 560 }
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 561 $entry_id = Entries::add( $entries_data );
798 562 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 563
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 564 $response = [
829 565 'success' => true,
830 - 'message' => $confirmation_message,
566 + 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
831 567 '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 - ),
568 + 'name' => $name,
569 + 'submission_id' => $entry_id,
570 + 'after_submit' => true,
847 571 ],
848 - 'redirect_url' => $redirect_url,
572 + 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ),
849 573 ];
850 574
851 575 $form_submit_response = apply_filters(
852 576 'srfm_form_submit_response',
@@ -855,9 +579,9 @@
855 579 'form_id' => $id ? intval( $id ) : '',
856 580 'entry_id' => intval( $entry_id ),
857 581 'to_emails' => $emails,
858 582 'form_name' => $name ? esc_attr( $name ) : '',
859 - 'message' => $confirmation_message,
583 + 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
860 584 'data' => $modified_message,
861 585 ]
862 586 );
863 587
@@ -864,21 +588,13 @@
864 588 do_action( 'srfm_form_submit', $form_submit_response );
865 589 } else {
866 590 $response = [
867 591 'success' => false,
868 - 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ),
592 + 'message' => __( 'Error submitting form', 'sureforms' ),
869 593 ];
870 594 }
871 595
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 );
596 + return $response;
881 597 }
882 598
883 599 /**
884 600 * Prepare submission data.
@@ -892,40 +608,8 @@
892 608 foreach ( $submission_data as $key => $value ) {
893 609 $parts = explode( '-lbl-', $key );
894 610 $label = '';
895 611
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 612 if ( ! empty( $parts[1] ) ) {
929 613 $tokens = explode( '-', $parts[1] );
930 614 if ( count( $tokens ) > 1 ) {
931 615 $label = implode( '-', array_slice( $tokens, 1 ) );
@@ -934,9 +618,9 @@
934 618 $fields = explode( '-', $parts[0] );
935 619
936 620 // Since the upload field returns an array of file URLs, we need to implode them with a comma.
937 621 if ( 'upload' === $fields[1] && ! empty( $value ) && is_array( $value ) ) {
938 - $modified_message[ $label ] = implode( ', ', array_map( 'rawurldecode', $value ) );
622 + $modified_message[ $label ] = urldecode( implode( ', ', $value ) );
939 623 } else {
940 624 $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) );
941 625 }
942 626 }
@@ -941,85 +625,12 @@
941 625 }
942 626 }
943 627 }
944 628
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 );
629 + return $modified_message;
958 630 }
959 631
960 632 /**
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 633 * Send Email.
1023 634 *
1024 635 * @param string $id Form ID.
1025 636 * @param array<mixed> $submission_data Submission data.
@@ -1028,162 +639,56 @@
1028 639 * @return array<mixed> Array containing the response data.
1029 640 */
1030 641 public static function send_email( $id, $submission_data, $form_data = [] ) {
1031 642 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
643 + $smart_tags = new Smart_Tags();
1032 644 $is_mail_sent = false;
1033 645 $emails = [];
1034 646
1035 - // Filter to determine whether the email notification should be sent.
1036 - $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data );
1037 -
1038 647 if ( is_iterable( $email_notification ) ) {
1039 648 $entries_db_instance = Entries::get_instance();
1040 - $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) );
649 + $log_key = $entries_db_instance->add_log( __( 'Email Notification Initiated', 'sureforms' ) );
1041 650
1042 651 foreach ( $email_notification as $notification ) {
1043 652 foreach ( $notification as $item ) {
1044 653 if ( true === $item['status'] ) {
1045 -
1046 - $parsed = self::parse_email_notification_template( $submission_data, $item, $form_data );
1047 -
1048 - // Allow filtering of the email data before it is sent.
1049 - $parsed = apply_filters( 'srfm_email_notification', $parsed, $submission_data, $item, $form_data );
1050 -
1051 - // Trigger an action before sending the email, allowing additional processing or logging.
1052 - do_action( 'srfm_before_email_send', $parsed, $submission_data, $item, $form_data );
1053 -
1054 - $notification_id = isset( $item['id'] ) ? intval( $item['id'] ) : 0;
1055 -
1056 - /**
1057 - * Filter to determine whether the email should be sent.
1058 - *
1059 - * @since 1.10.1
1060 - */
1061 - $should_send_email = apply_filters(
1062 - 'srfm_should_send_email',
1063 - true,
1064 - $notification_id,
1065 - $id,
1066 - $form_data,
1067 - );
1068 -
1069 - if ( ! wp_validate_boolean( $should_send_email ) ) {
1070 - continue;
654 + $from = Helper::get_string_value( get_option( 'admin_email' ) );
655 + $to = $smart_tags->process_smart_tags( $item['email_to'], $submission_data );
656 + $subject = $smart_tags->process_smart_tags( $item['subject'], $submission_data, $form_data );
657 + $email_body = $smart_tags->process_smart_tags( $item['email_body'], $submission_data, $form_data );
658 + $email_template = new Email_Template();
659 + $message = $email_template->render( $submission_data, $email_body );
660 + $headers = "From: {$from}\r\nX-Mailer: PHP/" . phpversion() . "\r\nContent-Type: text/html; charset=utf-8\r\n";
661 + if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) {
662 + $headers .= 'Reply-To:' . $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) . "\r\n";
663 + } else {
664 + $headers .= "Reply-To: {$from}\r\n";
1071 665 }
666 + if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) {
667 + $headers .= 'Cc:' . $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) . "\r\n";
668 + }
669 + if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) {
670 + $headers .= 'Bcc:' . $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) . "\r\n";
671 + }
1072 672
1073 - /**
1074 - * Temporary override the content type for wp_mail.
1075 - * This helps us from breaking of content type from other plugins.
1076 - *
1077 - * @since 1.2.2
1078 - */
1079 - add_filter(
1080 - 'wp_mail_content_type',
1081 - static function() {
1082 - return 'text/html'; // We need "text/html" content type to render our emails.
1083 - },
1084 - 99
1085 - );
673 + $sent = wp_mail( $to, $subject, $message, $headers );
1086 674
1087 - /**
1088 - * Start sending email.
1089 - * Wrapping it in the buffer because when some plugin such as zoho mail, overrides the wp_mail
1090 - * function and any exception is thrown ( Or printed ) from that plugin side, it affects the JSON response.
1091 - * So, to make sure such exceptions doesn't affect our JSON response, we are wrapping it inside buffer.
1092 - *
1093 - * Try-Catch does not work because the notice or errors might be echoed by other plugins rather than thrown as an exception.
1094 - *
1095 - * @since 1.2.2
1096 - */
1097 - $sent = false;
1098 - ob_start();
1099 - $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1100 - if ( ! $sent ) {
1101 - // Fallback to default PHP mail if for some reasons wp_mail fails.
1102 - $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1103 - }
1104 - $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
1105 -
1106 675 if ( is_int( $log_key ) ) {
1107 - if ( true === $sent ) {
1108 - $entries_db_instance->update_log(
1109 - $log_key,
1110 - null,
1111 - [
1112 - /* translators: Here, %s is the comma separated emails list. */
1113 - sprintf( __( 'Email notification recipient: %s', 'sureforms' ), esc_html( $parsed['to'] ) ),
1114 - ]
1115 - );
1116 - } else {
1117 - $reason = ! empty( $email_report )
1118 - ? esc_html( $email_report )
1119 - : ( ! Helper::is_any_smtp_plugin_active()
1120 - ? esc_html__( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' )
1121 - : esc_html__( 'Email sending failed for an unknown reason.', 'sureforms' )
1122 - );
1123 -
1124 - $entries_db_instance->update_log(
1125 - $log_key,
1126 - null,
1127 - [
1128 - sprintf(
1129 - /* translators: Here, %1$s is the comma separated emails list and %2$s is error report ( if any ). */
1130 - __(
1131 - 'Email server was unable to send the email notification. Recipient: %1$s. Reason: %2$s',
1132 - 'sureforms'
1133 - ),
1134 - esc_html( $parsed['to'] ),
1135 - $reason
1136 - ),
1137 - ]
1138 - );
1139 -
1140 - // Also record it in the debug log. The submission itself
1141 - // succeeded, so the visitor saw nothing wrong and nobody
1142 - // looks at the entry's own log until a ticket is already
1143 - // open. The recipient address is not included -- the log
1144 - // is downloadable and must not carry personal data.
1145 - Client_Logger::append(
1146 - Client_Logger::sanitize_entry(
1147 - [
1148 - 'type' => 'message',
1149 - 'form_id' => intval( $id ),
1150 - 'form_title' => Helper::get_string_value( get_the_title( intval( $id ) ) ),
1151 - 'message' => 'Email notification failed to send. ' . $reason,
1152 - ]
1153 - )
1154 - );
1155 -
1156 - // Its own category: the entry saved, so this is not a
1157 - // submission failure. The site owner is simply not being
1158 - // told about entries they did receive.
1159 - Client_Logger::record_failure(
1160 - 'notification',
1161 - intval( $id ),
1162 - Helper::get_string_value( get_the_title( intval( $id ) ) )
1163 - );
1164 - }
676 + $entries_db_instance->update_log(
677 + $log_key,
678 + null,
679 + [
680 + /* translators: Here, %s is the comma separated emails list. */
681 + $sent ? sprintf( __( 'Email notification sent to %s', 'sureforms' ), esc_html( $to ) ) : sprintf( __( 'Failed sending email notification to %s', 'sureforms' ), esc_html( $to ) ),
682 + ]
683 + );
1165 684 }
1166 685
1167 - // Trigger an action after the email is sent, allowing additional processing or logging.
1168 - do_action(
1169 - 'srfm_after_email_send',
1170 - $parsed,
1171 - $submission_data,
1172 - $item,
1173 - $form_data
1174 - );
1175 -
1176 686 $is_mail_sent = $sent;
1177 - $emails[] = $parsed['to'];
687 + $emails[] = $to;
1178 688 }
1179 689 }
1180 690 }
1181 -
1182 - if ( empty( $emails ) ) {
1183 - $entries_db_instance->reset_logs();
1184 - $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) );
1185 - }
1186 691 }
1187 692
1188 693 return [
1189 694 'success' => $is_mail_sent,
@@ -1191,81 +696,64 @@
1191 696 ];
1192 697 }
1193 698
1194 699 /**
1195 - * Validate unique field values for a specific form via AJAX.
700 + * Retrieve all entries data for a specific form ID to check for unique values.
1196 701 *
1197 - * Checks submitted field values against existing entries to determine
1198 - * if duplicates exist. Rate-limited to prevent data enumeration.
1199 - *
1200 702 * @since 0.0.1
1201 - * @since 2.7.0 Added rate limiting, form validation, and optimized query.
1202 703 * @return void
1203 704 */
1204 705 public function field_unique_validation() {
1205 - $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
1206 - $form_id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1207 -
1208 - if ( ! Submit_Token::verify( $token, $form_id ) ) {
1209 - wp_send_json_error( [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ] );
706 + if ( isset( $_POST['nonce'] ) && ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['nonce'] ) ), 'unique_validation_nonce' ) ) {
707 + $error_message = __( 'Nonce verification failed.', 'sureforms' );
708 + $error_data = [
709 + 'error' => $error_message,
710 + ];
711 + wp_send_json_error( $error_data );
1210 712 }
1211 713
1212 - if ( ! $form_id ) {
1213 - wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] );
1214 - }
714 + global $wpdb;
715 + $id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0;
716 + $meta_value = $id;
1215 717
1216 - // Validate the form exists and is published to prevent cross-form probing.
1217 - if ( 'publish' !== get_post_status( $form_id ) || 'sureforms_form' !== get_post_type( $form_id ) ) {
1218 - wp_send_json_error( [ 'error' => __( 'Invalid form.', 'sureforms' ) ] );
718 + if ( ! $meta_value ) {
719 + $error_message = __( 'Invalid form ID.', 'sureforms' );
720 + $error_data = [
721 + 'error' => $error_message,
722 + ];
723 + wp_send_json_error( $error_data );
1219 724 }
1220 725
1221 - // Rate limit: 10 requests per minute per IP per form.
1222 - if ( $this->is_unique_validation_rate_limited( $form_id ) ) {
1223 - wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 );
1224 - }
726 + $_POST = array_map( 'wp_unslash', $_POST );
1225 727
1226 - // SECURITY INVARIANT — only the fields the form itself marks unique may be
1227 - // probed through this unauthenticated handler. The allowlist is what keeps the
1228 - // lookup scoped to values a site owner opted into checking, rather than to
1229 - // stored submission data generally. A form with no unique fields therefore
1230 - // matches nothing and always answers with an empty set.
1231 - $unique_block_ids = $this->get_unique_field_block_ids( $form_id );
728 + // Get the entry IDs for the particualr form to perform unique field validation.
729 + $entry_ids = Entries::get_all_entry_ids_for_form( $id );
1232 730
1233 - // Extract and validate field values from POST data.
1234 - $skip_keys = [ 'action', 'token', 'id' ];
1235 - $duplicates = [];
731 + $all_form_entries = [];
732 + $keys = array_keys( $_POST );
733 + $length = count( $keys );
1236 734
1237 - foreach ( $_POST as $raw_key => $raw_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1238 - if ( in_array( $raw_key, $skip_keys, true ) ) {
1239 - continue;
1240 - }
735 + for ( $i = 3; $i < $length; $i++ ) {
736 + $key = $keys[ $i ];
737 + $value = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : '';
738 + $key = str_replace( '_', ' ', $keys[ $i ] );
1241 739
1242 - $field_key = str_replace( '_', ' ', sanitize_text_field( $raw_key ) );
1243 - $value = sanitize_text_field( wp_unslash( $raw_value ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above.
1244 -
1245 - // Only process SureForms field keys (they contain -lbl- in the name).
1246 - if ( false === strpos( $field_key, '-lbl-' ) ) {
1247 - continue;
740 + foreach ( $entry_ids as $entry_id ) {
741 + $entry_id = is_array( $entry_id ) ? Helper::get_integer_value( $entry_id['ID'] ) : 0;
742 + $form_data = Entries::get_form_data( $entry_id );
743 + if ( is_array( $form_data ) && isset( $form_data[ $key ] ) && $form_data[ $key ] === $value ) {
744 + $obj = [ $key => 'not unique' ];
745 + array_push( $all_form_entries, $obj );
746 + break;
747 + }
1248 748 }
749 + }
1249 750
1250 - if ( '' === $value ) {
1251 - continue;
1252 - }
751 + $results = [
752 + 'data' => $all_form_entries,
753 + ];
1253 754
1254 - // The key must resolve to a block this form configured as unique.
1255 - $block_id = Helper::get_block_id_from_key( $field_key );
1256 -
1257 - if ( '' === $block_id || ! isset( $unique_block_ids[ $block_id ] ) ) {
1258 - continue;
1259 - }
1260 -
1261 - // Single optimized query per field instead of loading all entries.
1262 - if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) {
1263 - $duplicates[] = [ $field_key => 'not unique' ];
1264 - }
1265 - }
1266 -
1267 - wp_send_json( [ 'data' => $duplicates ] );
755 + wp_send_json( $results );
1268 756 }
1269 757
1270 758 /**
1271 759 * Function to save allowed block data.
@@ -1273,9 +761,9 @@
1273 761 * @since 0.0.1
1274 762 * @return void
1275 763 */
1276 764 public function srfm_global_update_allowed_block() {
1277 - if ( ! Helper::current_user_can() ) {
765 + if ( ! current_user_can( 'manage_options' ) ) {
1278 766 wp_send_json_error();
1279 767 }
1280 768
1281 769 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
@@ -1296,9 +784,9 @@
1296 784 * @since 0.0.1
1297 785 * @return void
1298 786 */
1299 787 public function srfm_global_sidebar_enabled() {
1300 - if ( ! Helper::current_user_can() ) {
788 + if ( ! current_user_can( 'manage_options' ) ) {
1301 789 wp_send_json_error();
1302 790 }
1303 791
1304 792 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
@@ -1310,543 +798,6 @@
1310 798 Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar );
1311 799 wp_send_json_success();
1312 800 }
1313 801 wp_send_json_error();
1314 - }
1315 -
1316 - /**
1317 - * Send error response for reCAPTCHA validation failure.
1318 - *
1319 - * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1320 - * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1321 - * @since 1.7.0
1322 - * @return void
1323 - */
1324 - public function recaptcha_error_response( $type, $api_response ) {
1325 - $error_message = $this->recaptcha_error_message( $type, $api_response );
1326 - $response = array_merge(
1327 - [
1328 - 'api_response' => $api_response,
1329 - ],
1330 - $error_message
1331 - );
1332 -
1333 - wp_send_json_error( $response );
1334 - }
1335 -
1336 - /**
1337 - * Get the error message for a CAPTCHA validation failure based on the service type and API response.
1338 - *
1339 - * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'.
1340 - * @param array<mixed> $api_response The response returned from the CAPTCHA validation API.
1341 - * @since 1.7.0
1342 - * @return array<string,string> An associative array containing the error message and a detailed message.
1343 - */
1344 - public function recaptcha_error_message( $type, $api_response ) {
1345 -
1346 - if ( empty( $api_response['error-codes'] ) || ! is_array( $api_response['error-codes'] ) ) {
1347 - return [
1348 - 'detail_message' => __( 'Captcha validation failed. No error code provided.', 'sureforms' ),
1349 - 'message' => __( 'Captcha validation failed.', 'sureforms' ),
1350 - ];
1351 - }
1352 -
1353 - /**
1354 - * Note: The error codes are not translated because these messages are intended for debugging purposes.
1355 - * Translating them would make debugging difficult. These error messages are primarily for developers or administrators.
1356 - * A generic message will be displayed to the user, while detailed error information will be logged or shown in the console.
1357 - */
1358 -
1359 - // Google reCAPTCHA error codes.
1360 - // Reference: (https://developers.google.com/recaptcha/docs/verify#error-code-reference).
1361 - $google_recaptcha_error = [
1362 - 'missing-input-secret' => 'The secret parameter is missing.',
1363 - 'invalid-input-secret' => 'The secret parameter is invalid or malformed.',
1364 - 'missing-input-response' => 'The response parameter is missing.',
1365 - 'invalid-input-response' => 'The response parameter is invalid or malformed.',
1366 - 'bad-request' => 'The request is invalid or malformed.',
1367 - 'timeout-or-duplicate' => 'The response is no longer valid: either is too old or has been used previously.',
1368 - ];
1369 -
1370 - // hCaptcha error codes.
1371 - // Reference: (https://docs.hcaptcha.com/#siteverify-error-codes).
1372 - $hcaptcha_errors = [
1373 - 'missing-input-secret' => 'Your secret key is missing.',
1374 - 'invalid-input-secret' => 'Your secret key is invalid or malformed.',
1375 - 'missing-input-response' => 'The response parameter (verification token) is missing.',
1376 - 'invalid-input-response' => 'The response parameter (verification token) is invalid or malformed.',
1377 - 'expired-input-response' => 'The response parameter (verification token) is expired. (120s default)',
1378 - 'already-seen-response' => 'The response parameter (verification token) was already verified once.',
1379 - 'bad-request' => 'The request is invalid or malformed.',
1380 - 'missing-remoteip' => 'The remoteip parameter is missing.',
1381 - 'invalid-remoteip' => 'The remoteip parameter is not a valid IP address or blinded value.',
1382 - 'not-using-dummy-passcode' => 'You have used a testing sitekey but have not used its matching secret.',
1383 - 'sitekey-secret-mismatch' => 'The sitekey is not registered with the provided secret.',
1384 - ];
1385 -
1386 - // Cloudflare Turnstile error codes.
1387 - // Reference: (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).
1388 - $cf_turnstile_errors = [
1389 - 'missing-input-secret' => 'The secret parameter was not passed.',
1390 - 'invalid-input-secret' => 'The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.',
1391 - 'missing-input-response' => 'The response parameter (token) was not passed.',
1392 - '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.',
1393 - 'bad-request' => 'The request was rejected because it was malformed.',
1394 - '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.',
1395 - 'internal-error' => 'An internal error happened while validating the response. The request can be retried.',
1396 - ];
1397 -
1398 - $error_code = $api_response['error-codes'][0] ?? 'no-error-code';
1399 -
1400 - $captcha_title = '';
1401 - $captcha_message = '';
1402 - switch ( $type ) {
1403 - case 'g-recaptcha':
1404 - $captcha_title = __( 'Google reCAPTCHA', 'sureforms' );
1405 - $captcha_message = $google_recaptcha_error[ $error_code ];
1406 - break;
1407 - case 'hcaptcha':
1408 - $captcha_title = __( 'hCaptcha', 'sureforms' );
1409 - $captcha_message = $hcaptcha_errors[ $error_code ];
1410 - break;
1411 - case 'cf-turnstile':
1412 - $captcha_title = __( 'Cloudflare Turnstile', 'sureforms' );
1413 - $captcha_message = $cf_turnstile_errors[ $error_code ];
1414 - break;
1415 - default:
1416 - $captcha_title = __( 'Unknown Captcha', 'sureforms' );
1417 - $captcha_message = __( 'Invalid captcha type.', 'sureforms' );
1418 - break;
1419 - }
1420 -
1421 - $detail_message = sprintf(
1422 - '%s: %s <br> Error Code: %s',
1423 - $captcha_title,
1424 - $captcha_message ?? 'Unknown error occurred.',
1425 - $error_code
1426 - );
1427 -
1428 - $message = sprintf(
1429 - /* translators: %s is the captcha title. */
1430 - __( '%s verification failed. Please contact your site administrator.', 'sureforms' ),
1431 - $captcha_title
1432 - );
1433 -
1434 - return [
1435 - 'log_message' => $detail_message, // This variable is used for logging purposes, such as displaying detailed error information in the console on the front end.
1436 - 'message' => $message,
1437 - ];
1438 - }
1439 -
1440 - /**
1441 - * Sanitise and validate a Referer into a storable submission URL.
1442 - *
1443 - * The value is rebuilt from parsed components so a non-browser client cannot
1444 - * inject bits a real browser would never send (userinfo, fragment) or mismatch
1445 - * the legitimate origin's port. Anything that is not a same-origin http(s) URL,
1446 - * or is longer than 2048 chars, is rejected and returns an empty string.
1447 - *
1448 - * Uses esc_url_raw() rather than sanitize_text_field(): the latter strips
1449 - * percent-encoded octets (`%E0%A4...`), which mangles the URLs of translated
1450 - * pages whose slugs contain non-ASCII characters (e.g. WPML Hindi/Arabic
1451 - * permalinks) down to bare hyphens. esc_url_raw() preserves the percent-encoding
1452 - * so the recorded submission URL stays accurate.
1453 - *
1454 - * @param string $referer Raw (unslashed) Referer header value.
1455 - * @since 2.11.0
1456 - * @return string Same-origin http(s) URL, or empty string when invalid.
1457 - */
1458 - protected function normalize_submission_url( string $referer ): string {
1459 - $referer = esc_url_raw( $referer );
1460 -
1461 - if ( '' === $referer || strlen( $referer ) > 2048 ) {
1462 - return '';
1463 - }
1464 -
1465 - $parts = wp_parse_url( $referer );
1466 - $home_parts = wp_parse_url( home_url() );
1467 -
1468 - if (
1469 - ! is_array( $parts )
1470 - || ! is_array( $home_parts )
1471 - || ! isset( $parts['scheme'], $parts['host'], $home_parts['host'] )
1472 - || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true )
1473 - || 0 !== strcasecmp( (string) $parts['host'], (string) $home_parts['host'] )
1474 - || ( $parts['port'] ?? null ) !== ( $home_parts['port'] ?? null )
1475 - ) {
1476 - return '';
1477 - }
1478 -
1479 - $clean = $parts['scheme'] . '://' . $parts['host']
1480 - . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' )
1481 - . ( $parts['path'] ?? '' )
1482 - . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
1483 -
1484 - return esc_url_raw( $clean, [ 'http', 'https' ] );
1485 - }
1486 -
1487 - /**
1488 - * Check whether the given language code is known to the active multilingual
1489 - * provider (i.e. in its active-languages set or matches the default language).
1490 - *
1491 - * Used to reject crafted srfm-form-language hidden-input values that pass
1492 - * the BCP-47 shape regex but reference languages the site doesn't actually
1493 - * support.
1494 - *
1495 - * @param string $language Language code to check (e.g. 'hi', 'de-AT').
1496 - * @since 2.11.0
1497 - * @return bool True when the code is known, false otherwise.
1498 - */
1499 - protected function is_known_language( string $language ): bool {
1500 - if ( '' === $language ) {
1501 - return false;
1502 - }
1503 -
1504 - $provider = Multilingual_Manager::get_instance()->provider();
1505 -
1506 - // When no provider is active there's no authoritative set to check
1507 - // against. Accept whatever the visitor sent (shape-validated) so the
1508 - // column still reflects the visitor's intent on non-WPML sites.
1509 - if ( ! $provider->is_active() ) {
1510 - return true;
1511 - }
1512 -
1513 - // Default language is always considered known.
1514 - if ( $language === $provider->default_language() ) {
1515 - return true;
1516 - }
1517 -
1518 - // Use WPML's filter when available — works regardless of which
1519 - // multilingual plugin is the active provider, as Polylang implements
1520 - // the same filter for compatibility.
1521 - $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.
1522 - if ( is_array( $active ) && ! empty( $active ) ) {
1523 - return array_key_exists( $language, $active );
1524 - }
1525 -
1526 - // A provider IS active but its language list is unavailable. Rather than
1527 - // fail open and trust an arbitrary client-supplied code, accept it only when
1528 - // it matches the server-resolved current language. The caller already
1529 - // defaults $entry_language to current_language(), so this keeps mis-tagging
1530 - // to the server's own determination instead of the (cacheable) client value.
1531 - return $language === $provider->current_language();
1532 - }
1533 -
1534 - /**
1535 - * Collect the block IDs of the fields a form configures as unique.
1536 - *
1537 - * Derived from the stored form, never from the request — the whole point is that
1538 - * the client cannot nominate which fields are probeable. The frontend already
1539 - * sends only inputs rendered with data-unique="true", which comes from the same
1540 - * isUnique attribute, so this is the server-side mirror of what the client does.
1541 - *
1542 - * @param int $form_id Form ID.
1543 - *
1544 - * @since 2.12.3
1545 - * @return array<string,true> Unique field block IDs, keyed by block ID.
1546 - */
1547 - private function get_unique_field_block_ids( $form_id ) {
1548 - $form = get_post( $form_id );
1549 -
1550 - if ( ! $form instanceof \WP_Post || '' === $form->post_content ) {
1551 - return [];
1552 - }
1553 -
1554 - $visited_refs = [];
1555 - $block_ids = $this->collect_unique_field_block_ids( parse_blocks( $form->post_content ), $visited_refs );
1556 -
1557 - /**
1558 - * Filters the block IDs treated as unique fields for the AJAX uniqueness check.
1559 - *
1560 - * Lets add-ons whose fields a static parse of the form cannot see contribute
1561 - * their own unique fields.
1562 - *
1563 - * @since 2.12.3
1564 - *
1565 - * @param array<string,true> $block_ids Unique field block IDs, keyed by block ID.
1566 - * A plain list of IDs is accepted too and is
1567 - * normalised to this shape.
1568 - * @param int $form_id Form ID.
1569 - */
1570 - $filtered = apply_filters( 'srfm_unique_field_block_ids', $block_ids, $form_id );
1571 -
1572 - // Normalise rather than trust: the lookup is isset( $set[ $block_id ] ), so an
1573 - // add-on returning a plain list would silently disable uniqueness for the form
1574 - // instead of adding to it. A non-array return keeps the derived set.
1575 - return is_array( $filtered ) ? self::normalize_block_id_set( $filtered ) : $block_ids;
1576 - }
1577 -
1578 - /**
1579 - * Normalise a block-ID collection to a block ID => true map.
1580 - *
1581 - * Accepts both the documented map shape and a plain list of IDs.
1582 - *
1583 - * @param array<mixed> $block_ids Block IDs as a map or a list.
1584 - *
1585 - * @since 2.12.3
1586 - * @return array<string,true> Block IDs keyed by block ID.
1587 - */
1588 - private static function normalize_block_id_set( $block_ids ) {
1589 - $normalized = [];
1590 -
1591 - foreach ( $block_ids as $key => $value ) {
1592 - // List entry: the ID is the value. Map entry: the ID is the key.
1593 - $block_id = is_int( $key ) ? $value : $key;
1594 -
1595 - if ( is_string( $block_id ) && '' !== $block_id ) {
1596 - $normalized[ $block_id ] = true;
1597 - }
1598 - }
1599 -
1600 - return $normalized;
1601 - }
1602 -
1603 - /**
1604 - * Recursively collect block IDs of blocks whose isUnique attribute is enabled.
1605 - *
1606 - * Recurses into innerBlocks (repeater/container children) and expands
1607 - * reusable/synced patterns, mirroring Form_Styling::collect_form_block_ids().
1608 - *
1609 - * Note: parse_blocks() does NOT apply block.json defaults, unlike the render path.
1610 - * Every field block therefore has to keep isUnique defaulting to false — a block
1611 - * that defaults it to true would be serialised without the attribute and would be
1612 - * missed here while still rendering data-unique="true".
1613 - *
1614 - * @param array<mixed> $blocks Parsed blocks from parse_blocks().
1615 - * @param array<int, true> $visited_refs Reusable-block post IDs already expanded,
1616 - * keyed by ID — guards against reference cycles.
1617 - *
1618 - * @since 2.12.3
1619 - * @return array<string,true> Unique field block IDs, keyed by block ID.
1620 - */
1621 - private function collect_unique_field_block_ids( $blocks, &$visited_refs = [] ) {
1622 - $block_ids = [];
1623 -
1624 - foreach ( $blocks as $block ) {
1625 - if ( ! is_array( $block ) ) {
1626 - continue;
1627 - }
1628 -
1629 - $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
1630 -
1631 - if ( ! empty( $attrs['isUnique'] ) && ! empty( $attrs['block_id'] ) && is_scalar( $attrs['block_id'] ) ) {
1632 - $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true;
1633 - }
1634 -
1635 - // Reusable/synced pattern: expand the referenced wp_block post so a field
1636 - // living inside a pattern is seen like an inline block.
1637 - if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
1638 - $ref = absint( $attrs['ref'] );
1639 -
1640 - if ( $ref && ! isset( $visited_refs[ $ref ] ) ) {
1641 - $visited_refs[ $ref ] = true;
1642 - $ref_post = get_post( $ref );
1643 -
1644 - if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && 'publish' === $ref_post->post_status && '' !== $ref_post->post_content ) {
1645 - $block_ids += $this->collect_unique_field_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs );
1646 - }
1647 - }
1648 - }
1649 -
1650 - if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1651 - $block_ids += $this->collect_unique_field_block_ids( $block['innerBlocks'], $visited_refs );
1652 - }
1653 - }
1654 -
1655 - return $block_ids;
1656 - }
1657 -
1658 - /**
1659 - * Check if the current request is rate-limited for unique validation.
1660 - *
1661 - * Uses transients keyed by IP + form ID to throttle requests.
1662 - * Allows 10 requests per 60-second window per IP per form.
1663 - *
1664 - * @param int $form_id The form ID being validated.
1665 - * @since 2.7.0
1666 - * @return bool True if rate-limited (should block), false if allowed.
1667 - */
1668 - private function is_unique_validation_rate_limited( $form_id ) {
1669 - return $this->is_rate_limited( 'srfm_uv_', $form_id );
1670 - }
1671 -
1672 - /**
1673 - * Throttle a public endpoint to 10 requests per minute per IP per form.
1674 - *
1675 - * Shared by the uniqueness check and the client log route rather than
1676 - * duplicated, so a change to the window applies to both.
1677 - *
1678 - * @param string $prefix Transient key prefix, unique per endpoint.
1679 - * @param int $form_id The form ID the request relates to.
1680 - * @since 2.12.6
1681 - * @return bool True if rate-limited (should block), false if allowed.
1682 - */
1683 - private function is_rate_limited( $prefix, $form_id ) {
1684 - $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
1685 -
1686 - if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1687 - return true; // Fail closed if IP cannot be determined.
1688 - }
1689 -
1690 - $transient_key = $prefix . md5( $ip . '_' . $form_id );
1691 - $attempts = get_transient( $transient_key );
1692 -
1693 - if ( false === $attempts ) {
1694 - set_transient( $transient_key, 1, MINUTE_IN_SECONDS );
1695 - return false;
1696 - }
1697 -
1698 - $attempts_count = Helper::get_integer_value( $attempts );
1699 -
1700 - if ( $attempts_count >= 10 ) {
1701 - return true;
1702 - }
1703 -
1704 - set_transient( $transient_key, $attempts_count + 1, MINUTE_IN_SECONDS );
1705 - return false;
1706 - }
1707 -
1708 - /**
1709 - * Process and sanitize SureForms field data from submitted form data.
1710 - *
1711 - * @param array<mixed> $form_data Raw form data from submission.
1712 - *
1713 - * @since 1.11.0
1714 - * @return array Processed and sanitized submission data.
1715 - */
1716 - private function process_form_fields( $form_data ) {
1717 - $form_id = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? absint( $form_data['form-id'] ) : 0;
1718 -
1719 - $submission_data = [];
1720 -
1721 - $form_data_keys = array_keys( $form_data );
1722 - $form_data_count = count( $form_data );
1723 -
1724 - for ( $i = 0; $i < $form_data_count; $i++ ) {
1725 - $key = strval( $form_data_keys[ $i ] );
1726 -
1727 - /**
1728 - * This will allow to pass only sureforms fields
1729 - * checking -lbl- as thats mandatory for in key of sureforms fields.
1730 - */
1731 - if ( false === str_contains( $key, '-lbl-' ) ) {
1732 - continue;
1733 - }
1734 -
1735 - $value = $form_data[ $key ];
1736 -
1737 - $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
1738 -
1739 - $field_block_name = Helper::get_block_name_from_field( $field_name );
1740 -
1741 - /**
1742 - * Filters the field value during form submission processing.
1743 - *
1744 - * This filter allows the Pro plugin to process and modify field values before they are saved.
1745 - * The Pro plugin can implement custom sanitization, validation and escaping logic for its
1746 - * specialized field types. When this filter is used by Pro, the core plugin will skip its
1747 - * default validation.
1748 - *
1749 - * @since 1.11.0
1750 - *
1751 - * @param mixed $value The raw field value from form submission.
1752 - * @param array $field_data Field information array containing:
1753 - * - 'field_name': The field name/key
1754 - * - 'field_block_name': The block type identifier
1755 - * @return array {
1756 - * Processed field value data
1757 - *
1758 - * @type bool $is_processed Whether the value was processed by Pro plugin
1759 - * @type mixed $value The processed and sanitized field value
1760 - * }
1761 - */
1762 - $process_field_value = apply_filters(
1763 - 'srfm_process_field_value',
1764 - $value,
1765 - [
1766 - 'field_name' => $field_name,
1767 - 'field_block_name' => $field_block_name,
1768 - ]
1769 - );
1770 -
1771 - if ( is_array( $process_field_value ) && ! empty( $process_field_value['is_processed'] ) && ! empty( $process_field_value['value'] ) ) {
1772 - $submission_data[ $field_name ] = $process_field_value['value'];
1773 - continue;
1774 - }
1775 -
1776 - /**
1777 - * Need to remove this refactor array value handling.
1778 - *
1779 - * The current array-based value handling needs to be replaced with:
1780 - * 1. Block-specific value processing based on block type.
1781 - * 2. Move premium features to pro version.
1782 - * 3. Implement value processing through filters for extensibility.
1783 - *
1784 - * This will improve code organization and maintainability while properly
1785 - * separating free/pro functionality.
1786 - */
1787 -
1788 - // If the field is an array, encode the values. This is to add support for multi-upload field.
1789 - if ( is_array( $value ) ) {
1790 - $submission_data[ $field_name ] =
1791 - array_map(
1792 - static function ( $val ) {
1793 - return rawurlencode( $val );
1794 - },
1795 - $value
1796 - );
1797 - } else {
1798 - $submission_data[ $field_name ] = is_string( $value ) ? htmlspecialchars( $value ) : $value;
1799 - }
1800 - }
1801 -
1802 - /**
1803 - * Filters the submission data before preparing it for storage.
1804 - *
1805 - * The second parameter is a context array containing additional metadata
1806 - * about the submission. This array is extensible — new keys may be added
1807 - * in future versions without changing the filter signature.
1808 - *
1809 - * @since 2.6.0
1810 - *
1811 - * @param array<string,mixed> $submission_data Processed form submission data.
1812 - * @param array<string,mixed> $context {
1813 - * Additional context for the submission.
1814 - *
1815 - * @type int $form_id The ID of the form being submitted.
1816 - * }
1817 - */
1818 - return apply_filters(
1819 - 'srfm_before_prepare_submission_data',
1820 - $submission_data,
1821 - [
1822 - 'form_id' => $form_id,
1823 - ]
1824 - );
1825 - }
1826 -
1827 - /**
1828 - * Add From email and name in the header.
1829 - *
1830 - * @param array<mixed> $submission_data Submission data.
1831 - * @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'.
1832 - * @param Smart_Tags $smart_tags Smart Tags instance.
1833 - * @since 1.6.1
1834 - * @return string The formatted "From" email header.
1835 - */
1836 - private static function add_from_data_in_header( $submission_data, $item, $smart_tags ) {
1837 - $from_name = is_array( $item ) && ! empty( $item['from_name'] ) ? sanitize_text_field( Helper::get_string_value( $item['from_name'] ) ) : '{site_title}';
1838 - $from_email = is_array( $item ) && ! empty( $item['from_email'] ) ? Helper::get_string_value( $item['from_email'] ) : '{admin_email}';
1839 -
1840 - // Check if the email contains smart tags. If not, validate the email.
1841 - $is_valid_email = true;
1842 - if ( ! str_contains( $from_email, '{' ) && ! str_contains( $from_email, '}' ) ) {
1843 - $is_valid_email = filter_var( $from_email, FILTER_VALIDATE_EMAIL );
1844 - }
1845 - // if the email is not valid, set it to the admin email.
1846 - if ( ! $is_valid_email ) {
1847 - $from_email = Helper::get_string_value( get_option( 'admin_email' ) );
1848 - }
1849 -
1850 - 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";
1851 802 }
1852 803 }