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
← All changes | inc/form-submit.php +1373 -296 0.0.10 → 2.12.8 View file →
@@ -7,18 +7,15 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 +use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager;
11 12 use SRFM\Inc\Database\Tables\Entries;
12 -use SRFM\Inc\Traits\Get_Instance;
13 -use SRFM\Inc\Helper;
14 13 use SRFM\Inc\Email\Email_Template;
15 -use SRFM\Inc\Smart_Tags;
16 -use SRFM\Inc\Generate_Form_Markup;
17 -use WP_REST_Server;
18 14 use SRFM\Inc\Lib\Browser\Browser;
15 +use SRFM\Inc\Traits\Get_Instance;
19 16 use WP_Error;
20 -use WP_REST_Request;
17 +use WP_REST_Server;
21 18
22 19 if ( ! defined( 'ABSPATH' ) ) {
23 20 exit; // Exit if accessed directly.
24 21 }
@@ -42,8 +39,16 @@
42 39 */
43 40 protected $namespace = 'sureforms/v1';
44 41
45 42 /**
43 + * Addresses.
44 + *
45 + * @var string
46 + * @since 1.6.1
47 + */
48 + private $addresses = '';
49 +
50 + /**
46 51 * Constructor
47 52 *
48 53 * @since 0.0.1
49 54 */
@@ -48,8 +53,25 @@
48 53 * @since 0.0.1
49 54 */
50 55 public function __construct() {
51 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 );
52 74 add_action( 'wp_ajax_validation_ajax_action', [ $this, 'field_unique_validation' ] );
53 75 add_action( 'wp_ajax_nopriv_validation_ajax_action', [ $this, 'field_unique_validation' ] );
54 76 // for quick action bar.
55 77 add_action( 'wp_ajax_srfm_global_update_allowed_block', [ $this, 'srfm_global_update_allowed_block' ] );
@@ -68,23 +90,178 @@
68 90 '/submit-form',
69 91 [
70 92 'methods' => WP_REST_Server::EDITABLE,
71 93 'callback' => [ $this, 'handle_form_submission' ],
72 - 'permission_callback' => '__return_true',
94 + 'permission_callback' => [ $this, 'submit_form_permissions_check' ],
73 95 ]
74 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 + );
75 107 }
76 108
77 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 + /**
78 256 * Check whether a given request has permission access route.
79 257 *
80 258 * @since 0.0.1
81 - * @param WP_REST_Request $request Full details about the request.
82 - * @return WP_Error|boolean
259 + * @return WP_Error|bool
83 260 */
84 - public function permissions_check( $request ) {
85 - if ( ! current_user_can( 'manage_options' ) ) {
86 - return new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot access this route', 'sureforms' ), [ 'status' => rest_authorization_required_code() ] );
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() ] );
87 264 }
88 265 return true;
89 266 }
90 267
@@ -100,9 +277,9 @@
100 277
101 278 if ( empty( $secret_key ) || ! is_string( $secret_key ) ) {
102 279 return [
103 280 'success' => false,
104 - 'error' => 'Cloudflare Turnstile secret key is invalid.',
281 + 'error' => __( 'Cloudflare Turnstile secret key is invalid.', 'sureforms' ),
105 282 ];
106 283 }
107 284
108 285 if ( empty( $response ) ) {
@@ -107,9 +284,9 @@
107 284
108 285 if ( empty( $response ) ) {
109 286 return [
110 287 'success' => false,
111 - 'error' => 'Cloudflare Turnstile response is missing.',
288 + 'error' => __( 'Cloudflare Turnstile response is missing.', 'sureforms' ),
112 289 ];
113 290 }
114 291
115 292 $body = [
@@ -151,9 +328,9 @@
151 328
152 329 if ( empty( $secret_key ) || ! is_string( $secret_key ) ) {
153 330 return [
154 331 'success' => false,
155 - 'error' => 'hCaptcha secret key is invalid.',
332 + 'error' => __( 'hCaptcha secret key is invalid.', 'sureforms' ),
156 333 ];
157 334 }
158 335
159 336 if ( empty( $response ) ) {
@@ -158,9 +335,9 @@
158 335
159 336 if ( empty( $response ) ) {
160 337 return [
161 338 'success' => false,
162 - 'error' => 'hCaptcha response is missing.',
339 + 'error' => __( 'hCaptcha response is missing.', 'sureforms' ),
163 340 ];
164 341 }
165 342
166 343 $body = [
@@ -188,9 +365,8 @@
188 365
189 366 return json_decode( wp_remote_retrieve_body( $response ), true );
190 367 }
191 368
192 -
193 369 /**
194 370 * Handle Form Submission
195 371 *
196 372 * @param \WP_REST_Request $request Request object or array containing form data.
@@ -197,72 +373,83 @@
197 373 * @since 0.0.1
198 374 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
199 375 */
200 376 public function handle_form_submission( $request ) {
377 + $form_data = Helper::sanitize_by_field_type( $request->get_params() );
201 378
202 - $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
379 + if ( empty( $form_data ) || ! is_array( $form_data ) ) {
380 + wp_send_json_error( [ 'message' => __( 'Form data is not found.', 'sureforms' ) ] );
381 + }
203 382
204 - if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
383 + if ( empty( $form_data['form-id'] ) ) {
205 384 wp_send_json_error(
206 385 [
207 - 'data' => __( 'Nonce verification failed.', 'sureforms' ),
208 - 'status' => false,
386 + 'message' => __( 'Form ID is missing.', 'sureforms' ),
387 + 'position' => 'header',
209 388 ]
210 389 );
211 390 }
212 391
213 - $form_data = Helper::sanitize_by_field_type( $request->get_params() );
392 + $current_form_id = $form_data['form-id'];
214 393
215 - if ( empty( $form_data ) || ! is_array( $form_data ) ) {
216 - wp_send_json_error( __( 'Form data is not found.', 'sureforms' ) );
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 + );
217 413 }
218 414
219 - if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] && ! empty( $_FILES ) ) {
220 - add_filter( 'upload_dir', [ $this, 'change_upload_dir' ] );
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 + }
221 422
222 - foreach ( $_FILES as $field => $file ) {
223 - if ( is_array( $file['name'] ) ) {
224 - foreach ( $file['name'] as $key => $filename ) {
225 - $temp_path = $file['tmp_name'][ $key ];
226 - $file_size = $file['size'][ $key ];
227 - $file_type = $file['type'][ $key ];
228 - $file_error = $file['error'][ $key ];
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 + }
229 432
230 - if ( ! $filename && ! $temp_path && ! $file_size && ! $file_type ) {
231 - $form_data[ $field ][] = '';
232 - continue;
233 - }
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 );
234 437
235 - $uploaded_file = [
236 - 'name' => $filename,
237 - 'type' => $file_type,
238 - 'tmp_name' => $temp_path,
239 - 'error' => $file_error,
240 - 'size' => $file_size,
241 - ];
438 + $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id );
242 439
243 - $upload_overrides = [
244 - 'test_form' => false,
245 - ];
246 - $move_file = wp_handle_upload( $uploaded_file, $upload_overrides );
247 - remove_filter( 'upload_dir', [ $this, 'change_upload_dir' ] );
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 );
248 443
249 - if ( $move_file && ! isset( $move_file['error'] ) ) {
250 - $form_data[ $field ][] = $move_file['url'];
251 - } else {
252 - wp_send_json_error( __( 'File is not uploaded', 'sureforms' ) );
253 - }
254 - }
255 - } else {
256 - $form_data[ $field ][] = '';
257 - }
258 - }
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 + );
259 450 }
260 451
261 - if ( ! $form_data['form-id'] ) {
262 - wp_send_json_error( __( 'Form Id is missing.', 'sureforms' ) );
263 - }
264 - $current_form_id = $form_data['form-id'];
265 452 $security_type = Helper::get_meta_value( Helper::get_integer_value( $current_form_id ), '_srfm_captcha_security_type' );
266 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 ) ) : '';
267 454
268 455 if ( 'none' !== $security_type ) {
@@ -292,9 +479,9 @@
292 479
293 480 if ( 'cf-turnstile' === $security_type ) {
294 481 // Turnstile validation.
295 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'] ) : '';
296 - $cf_response = ! empty( $form_data['cf-turnstile-response'] ) ? $form_data['cf-turnstile-response'] : false;
483 + $cf_response = ! empty( $form_data['cf-turnstile-response'] ) && is_string( $form_data['cf-turnstile-response'] ) ? $form_data['cf-turnstile-response'] : '';
297 484
298 485 // if gdpr is enabled then set remote ip to empty.
299 486 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
300 487 $gdpr = false;
@@ -306,22 +493,21 @@
306 493 // check if ip logging is disabled in global settings then set remote ip to empty.
307 494 $gb_general_settinionsgs_opt = get_option( 'srfm_general_settings_options' );
308 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'] : '';
309 496
310 - $remote_ip = ( $gdpr ) || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
497 + $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
311 498
312 499 $turnstile_validation_result = self::validate_turnstile_token( $srfm_cf_turnstile_secret_key, $cf_response, $remote_ip );
313 500
314 501 // If the cloudflare validation fails, return an error.
315 502 if ( is_array( $turnstile_validation_result ) && isset( $turnstile_validation_result['success'] ) && false === $turnstile_validation_result['success'] ) {
316 - $error_message = isset( $turnstile_validation_result['error'] ) ? $turnstile_validation_result['error'] : 'Cloudflare Turnstile validation failed.';
317 - return new \WP_Error( 'cf_turnstile_error', $error_message, [ 'status' => 403 ] );
503 + $this->recaptcha_error_response( 'cf-turnstile', $turnstile_validation_result );
318 504 }
319 505 }
320 506
321 507 if ( 'hcaptcha' === $security_type ) {
322 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'] ) : '';
323 - $hcaptcha_response = ! empty( $form_data['h-captcha-response'] ) ? $form_data['h-captcha-response'] : false;
509 + $hcaptcha_response = ! empty( $form_data['h-captcha-response'] ) && is_string( $form_data['h-captcha-response'] ) ? $form_data['h-captcha-response'] : '';
324 510
325 511 // if gdpr is enabled then set remote ip to empty.
326 512 $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true );
327 513 $gdpr = false;
@@ -333,21 +519,20 @@
333 519 // check if ip logging is disabled in global settings then set remote ip to empty.
334 520 $gb_general_settings_options = get_option( 'srfm_general_settings_options' );
335 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'] : '';
336 522
337 - $remote_ip = ( $gdpr ) || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
523 + $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' );
338 524 $hcaptcha_validation_result = self::validate_hcaptcha_token( $srfm_hcaptcha_secret_key, $hcaptcha_response, $remote_ip );
339 525
340 526 // If the hcaptcha validation fails, return an error.
341 527 if ( is_array( $hcaptcha_validation_result ) && isset( $hcaptcha_validation_result['success'] ) && false === $hcaptcha_validation_result['success'] ) {
342 - $error_message = isset( $hcaptcha_validation_result['error'] ) ? $hcaptcha_validation_result['error'] : 'hCaptcha validation failed.';
343 - return new \WP_Error( 'hcaptcha_error', $error_message, [ 'status' => 403 ] );
528 + $this->recaptcha_error_response( 'hcaptcha', $hcaptcha_validation_result );
344 529 }
345 530 }
346 531
347 532 if ( isset( $form_data['srfm-honeypot-field'] ) && empty( $form_data['srfm-honeypot-field'] ) ) {
348 533 if ( ! empty( $google_captcha_secret_key ) ) {
349 - if ( isset( $form_data['sureforms_form_submit'] ) ) {
534 + if ( ! empty( $form_data['form-id'] ) ) {
350 535 $secret_key = $google_captcha_secret_key;
351 536 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
352 537 $captcha_response = $form_data['g-recaptcha-response'];
353 538 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
@@ -362,21 +547,37 @@
362 547 }
363 548 $sureforms_captcha_data = $data;
364 549
365 550 } else {
366 - return new \WP_Error( 'recaptcha_error', 'reCAPTCHA error.', [ 'status' => 403 ] );
551 + wp_send_json_error(
552 + [
553 + 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
554 + ]
555 + );
367 556 }
368 557 if ( isset( $sureforms_captcha_data['success'] ) && true === $sureforms_captcha_data['success'] ) {
369 558 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
370 - } else {
371 - return new \WP_Error( 'recaptcha_error', 'reCAPTCHA error.', [ 'status' => 403 ] );
372 559 }
373 - } else {
374 - return rest_ensure_response( $this->handle_form_entry( $form_data ) );
560 +
561 + $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
375 562 }
376 - } elseif ( ! isset( $form_data['srfm-honeypot-field'] ) ) {
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 +
377 578 if ( ! empty( $google_captcha_secret_key ) ) {
378 - if ( isset( $form_data['sureforms_form_submit'] ) ) {
579 + if ( ! empty( $form_data['form-id'] ) ) {
379 580 $secret_key = $google_captcha_secret_key;
380 581 $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
381 582 $captcha_response = $form_data['g-recaptcha-response'];
382 583 $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress;
@@ -391,39 +592,32 @@
391 592 }
392 593 $sureforms_captcha_data = $data;
393 594
394 595 } else {
395 - return new \WP_Error( 'recaptcha_error', 'reCAPTCHA error.', [ 'status' => 403 ] );
596 + wp_send_json_error(
597 + [
598 + 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
599 + ]
600 + );
396 601 }
397 602 if ( true === $sureforms_captcha_data['success'] ) {
398 603 return rest_ensure_response( $this->handle_form_entry( $form_data ) );
399 - } else {
400 - return new \WP_Error( 'recaptcha_error', 'reCAPTCHA error.', [ 'status' => 403 ] );
401 604 }
402 - } else {
403 - return rest_ensure_response( $this->handle_form_entry( $form_data ) );
605 +
606 + $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data );
404 607 }
405 - } else {
406 - return new \WP_Error( 'spam_detected', 'Spam Detected', [ 'status' => 403 ] );
608 +
609 + return rest_ensure_response( $this->handle_form_entry( $form_data ) );
407 610 }
408 611
612 + wp_send_json_error(
613 + [
614 + 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ),
615 + ]
616 + );
409 617 }
410 618
411 619 /**
412 - * Change the upload directory
413 - *
414 - * @param array<mixed> $dirs upload directory.
415 - * @return array<mixed>
416 - * @since 0.0.1
417 - */
418 - public function change_upload_dir( $dirs ) {
419 - $dirs['subdir'] = '/sureforms';
420 - $dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
421 - $dirs['url'] = $dirs['baseurl'] . $dirs['subdir'];
422 - return $dirs;
423 - }
424 -
425 - /**
426 620 * Send Email and Create Entry.
427 621 *
428 622 * @param array<string> $form_data Request object or array containing form data.
429 623 * @since 0.0.1
@@ -429,8 +623,25 @@
429 623 * @since 0.0.1
430 624 * @return array<mixed> Array containing the response data.
431 625 */
432 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 + }
433 644
434 645 $id = sanitize_text_field( $form_data['form-id'] );
435 646
436 647 // Get the compliance settings.
@@ -438,53 +649,48 @@
438 649 $gdpr = '';
439 650 $do_not_store_entries = '';
440 651
441 652 if ( is_array( $compliance ) && is_array( $compliance[0] ) ) {
442 - $gdpr = isset( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : '';
443 - $do_not_store_entries = isset( $compliance[0]['do_not_store_entries'] ) ? $compliance[0]['do_not_store_entries'] : '';
653 + $gdpr = $compliance[0]['gdpr'] ?? '';
654 + $do_not_store_entries = $compliance[0]['do_not_store_entries'] ?? '';
444 655 }
445 656
446 - $submission_data = [];
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 + }
447 664
448 - $form_data_keys = array_keys( $form_data );
449 - $form_data_count = count( $form_data );
665 + $form_data = apply_filters( 'srfm_before_fields_processing', $form_data );
450 666
451 - for ( $i = 4; $i < $form_data_count; $i++ ) {
452 - $key = strval( $form_data_keys[ $i ] );
453 - $value = $form_data[ $key ];
667 + $submission_data = $this->process_form_fields( $form_data );
454 668
455 - $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) );
669 + $modified_message = $this->prepare_submission_data( $submission_data );
456 670
457 - // If the field is an array, encode the values. This is to add support for multi-upload field.
458 - if ( is_array( $value ) ) {
459 - $submission_data[ $field_name ] =
460 - array_map(
461 - function ( $val ) {
462 - return rawurlencode( $val );
463 - },
464 - $value
465 - );
466 - } else {
467 - $submission_data[ $field_name ] = htmlspecialchars( $value );
468 - }
469 - }
671 + $form_before_submission_data = [
672 + 'form_id' => $id ? intval( $id ) : '',
673 + 'data' => $modified_message,
674 + ];
470 675
471 - $name = sanitize_text_field( get_the_title( intval( $id ) ) );
472 - $send_email = $this->send_email( $id, $submission_data );
473 - $is_mail_sent = false;
474 - $emails = [];
676 + /**
677 + * Fires before submission process starts.
678 + */
679 + do_action( 'srfm_before_submission', $form_before_submission_data );
475 680
476 - if ( $send_email ) {
477 - $emails = $send_email['emails'];
478 - $is_mail_sent = $send_email['success'];
479 - }
681 + $name = sanitize_text_field( get_the_title( intval( $id ) ) );
682 + $emails = [];
480 683
481 684 // Check if GDPR is enabled and do not store entries is enabled.
482 685 // If so, send email and do not store entries.
483 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 + }
484 692
485 - $modified_message = $this->prepare_submission_data( $submission_data );
486 -
487 693 $form_submit_response = [
488 694 'success' => true,
489 695 'form_id' => $id ? intval( $id ) : '',
490 696 'to_emails' => $emails,
@@ -499,123 +705,194 @@
499 705 * Hook for enabling background processes.
500 706 *
501 707 * @param array $form_data form data related to submission.
502 708 */
709 + $form_data['form_id'] = $id ? intval( $id ) : '';
503 710 do_action( 'srfm_after_submission_process', $form_data );
504 711
505 - $response = [
506 - 'success' => true,
507 - 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
508 - 'data' => [
712 + return [
713 + 'success' => true,
714 + 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
715 + 'data' => [
509 716 'name' => $name,
510 717 'after_submit' => false,
511 718 ],
719 + 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ),
512 720 ];
513 721
514 - return $response;
515 -
516 722 }
517 723
518 724 $global_setting_options = get_option( 'srfm_general_settings_options' );
519 725
520 - // If GDPR is enabled, do not store IP, browser, and device info.
521 - // If not, store IP, browser, and device info.
522 - $user_ip = '';
523 - $browser_name = '';
524 - $device_name = '';
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 = '';
525 732 if ( ! $gdpr ) {
526 733 $srfm_ip_log = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_ip_log'] ) ? $global_setting_options['srfm_ip_log'] : '';
527 734
528 - $user_ip = ( $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
735 + $user_ip = $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '';
529 736 $browser = new Browser();
530 737 $browser_name = sanitize_text_field( $browser->getBrowser() );
531 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 );
532 745 }
533 746
534 - $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) );
535 - $sender_email = '';
536 - $pattern = '/"label":"(.*?)"/';
747 + $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) );
748 + $pattern = '/"label":"(.*?)"/';
537 749 preg_match_all( $pattern, $form_markup, $matches );
538 - $labels = $matches[1];
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 + }
539 772
540 - $honeypot = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_honeypot'] ) ? $global_setting_options['srfm_honeypot'] : '';
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 + }
541 787
542 - $key = strval( $form_data_keys[4] );
543 - $first_field_value = $form_data[ $key ];
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 + );
544 796
545 - if ( $honeypot ) {
546 - $key = strval( $form_data_keys[5] );
547 - $first_field_value = $form_data[ $key ];
548 - }
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 );
549 801
550 - $new_post = [
551 - 'post_status' => 'publish',
552 - 'post_type' => 'sureforms_entry',
553 - ];
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 + }
554 812
555 - $post_id = wp_insert_post( $new_post );
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();
556 819
557 - $post_title = __( 'Entry #', 'sureforms' ) . $post_id;
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 + }
558 825
559 - $post_args = [
560 - 'ID' => $post_id,
561 - 'post_title' => $post_title,
562 - ];
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 + }
563 832
564 - wp_update_post( $post_args );
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 );
565 835
566 - update_post_meta( $post_id, 'srfm_entry_meta', $submission_data );
567 - add_post_meta( $post_id, 'srfm_entry_meta_form_id', $id, true );
568 - if ( $post_id ) {
569 - $submission_info = [
570 - 'user_ip' => $user_ip,
571 - 'browser_name' => $browser_name,
572 - 'device_name' => $device_name,
573 - ];
836 + if ( $provider->is_active() && '' !== $entry_language ) {
837 + $provider->restore_language();
838 + }
574 839
575 - update_post_meta( $post_id, 'srfm_entry_meta', $submission_data );
576 - update_post_meta( $post_id, '_srfm_entry_form_id', $id );
840 + $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) );
577 841
578 - wp_set_object_terms( $post_id, $id, 'sureforms_tax' );
579 -
580 842 $response = [
581 - 'success' => true,
582 - 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
583 - 'data' => [
584 - 'name' => $name,
585 - 'submission_id' => $post_id,
586 - 'after_submit' => true,
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 + ),
587 861 ],
862 + 'redirect_url' => $redirect_url,
588 863 ];
589 864
590 - $modified_message = $this->prepare_submission_data( $submission_data );
591 -
592 - $form_submit_response = [
593 - 'success' => true,
594 - 'form_id' => $id ? intval( $id ) : '',
595 - 'to_emails' => $emails,
596 - 'form_name' => $name ? esc_attr( $name ) : '',
597 - 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ),
598 - 'data' => $modified_message,
599 - ];
600 -
601 - do_action( 'srfm_form_submit', $form_submit_response );
602 -
603 - Entries::add(
865 + $form_submit_response = apply_filters(
866 + 'srfm_form_submit_response',
604 867 [
605 - 'form_id' => $id,
606 - 'user_data' => $submission_data,
607 - 'submission_info' => $submission_info,
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,
608 875 ]
609 876 );
877 +
878 + do_action( 'srfm_form_submit', $form_submit_response );
610 879 } else {
611 880 $response = [
612 881 'success' => false,
613 - 'message' => __( 'Error submitting form', 'sureforms' ),
882 + 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ),
614 883 ];
615 884 }
616 885
617 - return $response;
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 );
618 895 }
619 896
620 897 /**
621 898 * Prepare submission data.
@@ -629,81 +906,346 @@
629 906 foreach ( $submission_data as $key => $value ) {
630 907 $parts = explode( '-lbl-', $key );
631 908 $label = '';
632 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 +
633 942 if ( ! empty( $parts[1] ) ) {
634 943 $tokens = explode( '-', $parts[1] );
635 944 if ( count( $tokens ) > 1 ) {
636 945 $label = implode( '-', array_slice( $tokens, 1 ) );
637 946 }
638 - $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) );
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 + }
639 956 }
640 957 }
641 958
642 - return $modified_message;
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 );
643 972 }
644 973
645 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 + /**
646 1036 * Send Email.
647 1037 *
648 - * @param string $id Form ID.
649 - * @param array<mixed> $submission_data Submission data.
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.
650 1041 * @since 0.0.1
651 1042 * @return array<mixed> Array containing the response data.
652 1043 */
653 - public static function send_email( $id, $submission_data ) {
1044 + public static function send_email( $id, $submission_data, $form_data = [] ) {
654 1045 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
655 - $smart_tags = new Smart_Tags();
656 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;
657 1053 $emails = [];
658 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 +
659 1058 if ( is_iterable( $email_notification ) ) {
660 1059 $entries_db_instance = Entries::get_instance();
661 - $log_key = $entries_db_instance->add_log( __( 'Email Notification Initiated', 'sureforms' ) );
1060 + $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) );
662 1061
663 1062 foreach ( $email_notification as $notification ) {
664 1063 foreach ( $notification as $item ) {
665 1064 if ( true === $item['status'] ) {
666 - $from = Helper::get_string_value( get_option( 'admin_email' ) );
667 - $to = $smart_tags->process_smart_tags( $item['email_to'], $submission_data );
668 - $subject = $smart_tags->process_smart_tags( $item['subject'], $submission_data );
669 - $email_body = $smart_tags->process_smart_tags( $item['email_body'], $submission_data );
670 - $email_template = new Email_Template();
671 - $message = $email_template->render( $submission_data, $email_body );
672 - $headers = "
673 - From: $from\r\n" .
674 - 'X-Mailer: PHP/' . phpversion() . "\r\n" .
675 - "Content-Type: text/html; charset=utf-8\r\n";
676 - if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) {
677 - $headers .= 'Reply-To:' . $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) . "\r\n";
678 - } else {
679 - $headers .= "Reply-To: $from\r\n";
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;
680 1091 }
681 - if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) {
682 - $headers .= 'Cc:' . $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) . "\r\n";
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 + }
683 1130 }
684 - if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) {
685 - $headers .= 'Bcc:' . $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) . "\r\n";
1131 + $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
1132 +
1133 + if ( true !== $sent ) {
1134 + $notification_failed = true;
686 1135 }
687 1136
688 - $sent = wp_mail( $to, $subject, $message, $headers );
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 + );
689 1154
690 - if ( is_int( $log_key ) ) {
691 - $entries_db_instance->update_log(
692 - $log_key,
693 - null,
694 - [
695 - /* translators: Here, %s is the comma separated emails list. */
696 - $sent ? sprintf( __( 'Email notification sent to %s', 'sureforms' ), esc_html( $to ) ) : sprintf( __( 'Failed sending email notification to %s', 'sureforms' ) ),
697 - ]
698 - );
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 + }
699 1196 }
700 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 +
701 1207 $is_mail_sent = $sent;
702 - $emails[] = $to;
1208 + $emails[] = $parsed['to'];
703 1209 }
704 1210 }
705 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 + }
706 1248 }
707 1249
708 1250 return [
709 1251 'success' => $is_mail_sent,
@@ -708,88 +1250,86 @@
708 1250 return [
709 1251 'success' => $is_mail_sent,
710 1252 'emails' => $emails,
711 1253 ];
712 -
713 1254 }
714 1255
715 1256 /**
716 - * Retrieve all entries data for a specific form ID to check for unique values.
1257 + * Validate unique field values for a specific form via AJAX.
717 1258 *
1259 + * Checks submitted field values against existing entries to determine
1260 + * if duplicates exist. Rate-limited to prevent data enumeration.
1261 + *
718 1262 * @since 0.0.1
1263 + * @since 2.7.0 Added rate limiting, form validation, and optimized query.
719 1264 * @return void
720 1265 */
721 1266 public function field_unique_validation() {
722 - if ( isset( $_POST['nonce'] ) && ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['nonce'] ) ), 'unique_validation_nonce' ) ) {
723 - $error_message = 'Nonce verification failed.';
724 - $error_data = [
725 - 'error' => $error_message,
726 - ];
727 - wp_send_json_error( $error_data );
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' ) ] );
728 1272 }
729 1273
730 - global $wpdb;
731 - $id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0;
732 - $meta_value = $id;
1274 + if ( ! $form_id ) {
1275 + wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] );
1276 + }
733 1277
734 - if ( ! $meta_value ) {
735 - $error_message = 'Invalid form ID.';
736 - $error_data = [
737 - 'error' => $error_message,
738 - ];
739 - wp_send_json_error( $error_data );
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' ) ] );
740 1281 }
741 1282
742 - $_POST = array_map( 'wp_unslash', $_POST );
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 + }
743 1287
744 - $taxonomy = 'sureforms_tax';
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 );
745 1294
746 - $args = [
747 - 'post_type' => SRFM_ENTRIES_POST_TYPE,
748 - 'tax_query' // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query. -- We require tax_query for this function to work.
749 - => [
750 - [
751 - 'taxonomy' => $taxonomy,
752 - 'field' => 'slug',
753 - 'terms' => $id,
754 - ],
755 - ],
756 - 'fields' => 'ids',
757 - ];
758 - $query = new \WP_Query( $args );
1295 + // Extract and validate field values from POST data.
1296 + $skip_keys = [ 'action', 'token', 'id' ];
1297 + $duplicates = [];
759 1298
760 - $post_ids = $query->posts;
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 + }
761 1303
762 - wp_reset_postdata();
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.
763 1306
764 - $all_form_entries = [];
765 - $keys = array_keys( $_POST );
766 - $length = count( $keys );
1307 + // Only process SureForms field keys (they contain -lbl- in the name).
1308 + if ( false === strpos( $field_key, '-lbl-' ) ) {
1309 + continue;
1310 + }
767 1311
768 - for ( $i = 3; $i < $length; $i++ ) {
769 - $key = $keys[ $i ];
770 - $value = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : '';
771 - $key = str_replace( '_', ' ', $keys[ $i ] );
1312 + if ( '' === $value ) {
1313 + continue;
1314 + }
772 1315
773 - foreach ( $post_ids as $post_id ) {
774 - $post_id = Helper::get_integer_value( $post_id );
775 - $meta_values = get_post_meta( $post_id, 'srfm_entry_meta', true );
776 - if ( is_array( $meta_values ) && isset( $meta_values[ $key ] ) && $meta_values[ $key ] === $value ) {
777 - $obj = [ $key => 'not unique' ];
778 - array_push( $all_form_entries, $obj );
779 - break;
780 - }
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;
781 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 + }
782 1327 }
783 1328
784 - $results = [
785 - 'data' => $all_form_entries,
786 - ];
787 -
788 - wp_send_json( $results );
1329 + wp_send_json( [ 'data' => $duplicates ] );
789 1330 }
790 1331
791 -
792 1332 /**
793 1333 * Function to save allowed block data.
794 1334 *
795 1335 * @since 0.0.1
@@ -795,9 +1335,9 @@
795 1335 * @since 0.0.1
796 1336 * @return void
797 1337 */
798 1338 public function srfm_global_update_allowed_block() {
799 - if ( ! current_user_can( 'manage_options' ) ) {
1339 + if ( ! Helper::current_user_can() ) {
800 1340 wp_send_json_error();
801 1341 }
802 1342
803 1343 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
@@ -818,9 +1358,9 @@
818 1358 * @since 0.0.1
819 1359 * @return void
820 1360 */
821 1361 public function srfm_global_sidebar_enabled() {
822 - if ( ! current_user_can( 'manage_options' ) ) {
1362 + if ( ! Helper::current_user_can() ) {
823 1363 wp_send_json_error();
824 1364 }
825 1365
826 1366 if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) {
@@ -832,6 +1372,543 @@
832 1372 Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar );
833 1373 wp_send_json_success();
834 1374 }
835 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";
836 1913 }
837 1914 }