PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.11.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.11.1
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
sureforms / inc / helper.php

helper.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.11.1, at inc/helper.php

2,645 lines 92.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sureforms Submit Class file.
4 *
5 * @package sureforms.
6 * @since 0.0.1
7 */
8
9 namespace SRFM\Inc;
10
11 use SRFM\Inc\Compatibility\Multilingual\String_Translator;
12 use SRFM\Inc\Database\Tables\Entries;
13 use SRFM\Inc\Traits\Get_Instance;
14 use WP_Error;
15 use WP_Post;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit; // Exit if accessed directly.
19 }
20
21 /**
22 * Sureforms Helper Class.
23 *
24 * @since 0.0.1
25 */
26 class Helper {
27 use Get_Instance;
28
29 /**
30 * Allowed HTML tags for SVG.
31 *
32 * @var array<string, array<string, bool>>
33 */
34 public static $allowed_tags_svg = [
35 'span' => [
36 'class' => true,
37 'aria-hidden' => true,
38 ],
39 'svg' => [
40 'xmlns' => true,
41 'width' => true,
42 'height' => true,
43 'viewBox' => true,
44 'fill' => true,
45 ],
46 'path' => [
47 'd' => true,
48 'stroke' => true,
49 'stroke-opacity' => true,
50 'stroke-width' => true,
51 'stroke-linecap' => true,
52 'stroke-linejoin' => true,
53 ],
54 ];
55
56 /**
57 * Sureforms SVGs.
58 *
59 * @var mixed srfm_svgs
60 */
61 private static $srfm_svgs = null;
62
63 /**
64 * Get common error message.
65 *
66 * @since 0.0.2
67 * @return array<string>
68 */
69 public static function get_common_err_msg() {
70 $translator = String_Translator::get_instance();
71 return [
72 'required' => $translator->translate_validation_message( 'srfm_required_field', __( 'This field is required.', 'sureforms' ) ),
73 'unique' => $translator->translate_validation_message( 'srfm_unique_field', __( 'Value needs to be unique.', 'sureforms' ) ),
74 ];
75 }
76
77 /**
78 * Convert a file URL to a file path.
79 *
80 * @param string $file_url The URL of the file.
81 *
82 * @since 1.3.0
83 * @return string The file path.
84 */
85 public static function convert_fileurl_to_filepath( $file_url ) {
86 static $upload_dir = null;
87 if ( ! $upload_dir ) {
88 // Internally cache the upload directory.
89 $upload_dir = wp_get_upload_dir();
90 }
91 return wp_normalize_path( str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $file_url ) );
92 }
93
94 /**
95 * Checks if current value is string or else returns default value
96 *
97 * @param mixed $data data which need to be checked if is string.
98 *
99 * @since 0.0.1
100 * @return string
101 */
102 public static function get_string_value( $data ) {
103 if ( is_scalar( $data ) ) {
104 return (string) $data;
105 }
106 if ( is_object( $data ) && method_exists( $data, '__toString' ) ) {
107 return $data->__toString();
108 }
109 if ( is_null( $data ) ) {
110 return '';
111 }
112 return '';
113 }
114 /**
115 * Checks if current value is number or else returns default value
116 *
117 * @param mixed $value data which need to be checked if is string.
118 * @param int $base value can be set is $data is not a string, defaults to empty string.
119 *
120 * @since 0.0.1
121 * @return int
122 */
123 public static function get_integer_value( $value, $base = 10 ) {
124 if ( is_numeric( $value ) ) {
125 return (int) $value;
126 }
127 if ( is_string( $value ) ) {
128 $trimmed_value = trim( $value );
129 return intval( $trimmed_value, $base );
130 }
131 return 0;
132 }
133
134 /**
135 * Validate a date string in Y-m-d format.
136 *
137 * @param string $date The date string to validate.
138 * @since 2.6.0
139 * @return bool
140 */
141 public static function validate_date( string $date ): bool {
142 $d = \DateTime::createFromFormat( 'Y-m-d', $date );
143 return $d && $d->format( 'Y-m-d' ) === $date;
144 }
145
146 /**
147 * Returns a boolean representation of the given value.
148 *
149 * @param mixed $data Data which needs to be converted to boolean.
150 *
151 * @since 2.5.2
152 * @return bool
153 */
154 public static function get_boolean_value( $data ) {
155 return (bool) $data;
156 }
157
158 /**
159 * Checks if current value is an array or else returns default value
160 *
161 * @param mixed $data Data which needs to be checked if it is an array.
162 *
163 * @since 0.0.3
164 * @return array
165 */
166 public static function get_array_value( $data ) {
167 if ( is_array( $data ) ) {
168 return $data;
169 }
170 if ( is_null( $data ) ) {
171 return [];
172 }
173 return (array) $data;
174 }
175
176 /**
177 * Extracts the field type from the dynamic field key ( or field slug ).
178 *
179 * @param string $field_key Dynamic field key.
180 * @since 0.0.6
181 * @return string Extracted field type.
182 */
183 public static function get_field_type_from_key( $field_key ) {
184
185 if ( false === strpos( $field_key, '-lbl-' ) ) {
186 return '';
187 }
188
189 return trim( explode( '-', $field_key )[1] );
190 }
191
192 /**
193 * Extracts the field label from the dynamic field key ( or field slug ).
194 *
195 * @param string $field_key Dynamic field key.
196 * @since 1.1.1
197 * @return string Extracted field label.
198 */
199 public static function get_field_label_from_key( $field_key ) {
200 if ( false === strpos( $field_key, '-lbl-' ) ) {
201 return '';
202 }
203
204 $label = explode( '-lbl-', $field_key )[1];
205 // Getting the encrypted label. we are removing the block slug here.
206 $label = explode( '-', $label )[0];
207
208 return $label ? html_entity_decode( self::decrypt( $label ) ) : '';
209 }
210
211 /**
212 * Extracts the block ID from the dynamic field key ( or field slug ).
213 *
214 * @param string $field_key Dynamic field key.
215 * @since 1.6.1
216 * @return string Extracted block ID.
217 */
218 public static function get_block_id_from_key( $field_key ) {
219 // Check if the key contains the block ID identifier.
220 if ( strpos( $field_key, 'srfm-' ) === 0 && strpos( $field_key, '-lbl-' ) === false ) {
221 return ''; // Return empty if the key format is invalid.
222 }
223
224 $parts = explode( '-lbl-', $field_key );
225 if ( isset( $parts[0] ) ) {
226 $block_id = explode( '-', $parts[0] );
227 if ( is_array( $block_id ) && ! empty( $block_id ) ) {
228 return end( $block_id );
229 }
230 }
231 return '';
232 }
233
234 /**
235 * Returns the proper sanitize callback functions according to the field type.
236 *
237 * @param string $field_type HTML field type.
238 * @since 0.0.6
239 * @return callable Returns sanitize callbacks according to the provided field type.
240 */
241 public static function get_field_type_sanitize_function( $field_type ) {
242 $callbacks = apply_filters(
243 'srfm_field_type_sanitize_functions',
244 [
245 'url' => 'esc_url_raw',
246 'input' => 'sanitize_text_field',
247 'number' => [ self::class, 'sanitize_number' ],
248 'email' => 'sanitize_email',
249 'textarea' => [ self::class, 'sanitize_textarea' ],
250 ]
251 );
252
253 return $callbacks[ $field_type ] ?? 'sanitize_text_field';
254 }
255
256 /**
257 * Sanitizes a numeric value.
258 *
259 * This function checks if the input value is numeric. If it is numeric, it sanitizes
260 * the value to ensure it's a float or integer, allowing for fractions and thousand separators.
261 * If the value is not numeric, it sanitizes it as a text field.
262 *
263 * @param mixed $value The value to be sanitized.
264 * @since 0.0.6
265 * @return int|float|string The sanitized value.
266 */
267 public static function sanitize_number( $value ) {
268 if ( ! is_numeric( $value ) ) {
269 // phpcs:ignore /** @phpstan-ignore-next-line */
270 return sanitize_text_field( $value ); // If it is not numeric, then let user get some sanitized data to view.
271 }
272
273 // phpcs:ignore /** @phpstan-ignore-next-line */
274 return sanitize_text_field( filter_var( $value, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION | FILTER_FLAG_ALLOW_THOUSAND ) );
275 }
276
277 /**
278 * Sanitize a CSS value to prevent injection.
279 *
280 * Strips characters that can break out of a CSS property value context
281 * and removes dangerous CSS functions while preserving safe ones
282 * (rgb, hsl, linear-gradient, etc.).
283 *
284 * @param mixed $value Raw CSS value.
285 * @return string Sanitized CSS value.
286 * @since 2.7.0
287 */
288 public static function sanitize_css_value( $value ) {
289 $value = self::get_string_value( $value );
290 // Strip characters that can break out of a CSS property value context.
291 $value = preg_replace( '/[{}<>;\\\\"\'`]/', '', $value ) ?? '';
292 // Remove dangerous CSS functions (url, expression, import, etc.) while preserving safe ones (rgb, hsl, linear-gradient, etc.).
293 return preg_replace( '/\b(url|expression|import|javascript)\s*\(/i', '(', $value ) ?? '';
294 }
295
296 /**
297 * This function sanitizes the submitted form data according to the field type.
298 *
299 * @param array<mixed> $form_data $form_data User submitted form data.
300 * @since 0.0.6
301 * @return array<mixed> $result Sanitized form data.
302 */
303 public static function sanitize_by_field_type( $form_data ) {
304 $result = [];
305
306 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
307 return $result;
308 }
309
310 foreach ( $form_data as $field_key => &$value ) {
311 $field_type = self::get_field_type_from_key( $field_key );
312 $sanitize_function = self::get_field_type_sanitize_function( $field_type );
313 $sanitized_data = is_array( $value ) ? self::sanitize_by_field_type( $value ) : call_user_func( $sanitize_function, $value );
314
315 $result[ $field_key ] = $sanitized_data;
316 }
317
318 return $result;
319 }
320
321 /**
322 * Sanitize a value based on its PHP type.
323 *
324 * Recursively sanitizes arrays while preserving native PHP types (bool, int, float).
325 * Use this for complex object metas with many properties where per-field callbacks are impractical.
326 *
327 * @param mixed $value The value to sanitize.
328 * @param int $depth Current recursion depth. Values nested beyond 10 levels are discarded.
329 * @since 2.8.0
330 * @return mixed The sanitized value.
331 */
332 public static function sanitize_by_type( $value, int $depth = 0 ) {
333 if ( $depth > 10 ) {
334 return '';
335 }
336 if ( is_array( $value ) ) {
337 $sanitized = [];
338 foreach ( $value as $key => $val ) {
339 $sanitized[ sanitize_text_field( (string) $key ) ] = self::sanitize_by_type( $val, $depth + 1 );
340 }
341 return $sanitized;
342 }
343 if ( is_bool( $value ) ) {
344 return $value;
345 }
346 if ( is_int( $value ) ) {
347 return intval( $value );
348 }
349 if ( is_float( $value ) ) {
350 return floatval( $value );
351 }
352 if ( is_string( $value ) ) {
353 return sanitize_text_field( $value );
354 }
355 return '';
356 }
357
358 /**
359 * This function performs array_map for multi dimensional array
360 *
361 * @param string $function function name to be applied on each element on array.
362 * @param array<mixed> $data_array array on which function needs to be performed.
363 * @return array<mixed>
364 * @since 0.0.1
365 */
366 public static function sanitize_recursively( $function, $data_array ) {
367 $response = [];
368 if ( is_array( $data_array ) ) {
369 if ( ! is_callable( $function ) ) {
370 return $data_array;
371 }
372 foreach ( $data_array as $key => $data ) {
373 $val = is_array( $data ) ? self::sanitize_recursively( $function, $data ) : $function( $data );
374 $response[ $key ] = $val;
375 }
376 }
377
378 return $response;
379 }
380
381 /**
382 * Generates common markup liked label, etc
383 *
384 * @param int|string $form_id form id.
385 * @param string $type Type of form markup.
386 * @param string $label Label for the form markup.
387 * @param string $slug Slug for the form markup.
388 * @param string $block_id Block id for the form markup.
389 * @param bool $required If field is required or not.
390 * @param string $help Help for the form markup.
391 * @param string $error_msg Error message for the form markup.
392 * @param bool $is_unique Check if the field is unique.
393 * @param string $duplicate_msg Duplicate message for field.
394 * @param bool $override Override for error markup.
395 * @return string
396 * @since 0.0.1
397 */
398 public static function generate_common_form_markup( $form_id, $type, $label = '', $slug = '', $block_id = '', $required = false, $help = '', $error_msg = '', $is_unique = false, $duplicate_msg = '', $override = false ) {
399 $duplicate_msg = $duplicate_msg ? ' data-unique-msg="' . esc_attr( $duplicate_msg ) . '"' : '';
400
401 $markup = '';
402 $show_labels_as_placeholder = get_post_meta( self::get_integer_value( $form_id ), '_srfm_use_label_as_placeholder', true );
403 $show_labels_as_placeholder = $show_labels_as_placeholder ? self::get_string_value( $show_labels_as_placeholder ) : false;
404
405 $required_sign = apply_filters( 'srfm_value_after_label_placeholder', ' *' );
406
407 if ( ! is_string( $required_sign ) ) {
408 $required_sign = ' *';
409 }
410
411 switch ( $type ) {
412 case 'label':
413 if ( $label ) {
414 ob_start();
415 ?>
416 <label id="srfm-label-<?php echo esc_attr( $block_id ); ?>" for="srfm-<?php echo esc_attr( $slug ); ?>-<?php echo esc_attr( $block_id ); ?>" class="srfm-block-label">
417 <?php echo esc_html( $label ); ?>
418 <?php if ( $required ) { ?>
419 <span class="srfm-required" aria-hidden="true"> *</span>
420 <?php } ?>
421 </label>
422 <?php
423 $markup = ob_get_clean();
424 }
425 break;
426 case 'help':
427 if ( $help ) {
428 ob_start();
429 ?>
430 <div class="srfm-description" id="srfm-description-<?php echo esc_attr( $block_id ); ?>">
431 <?php echo esc_html( $help ); ?>
432 </div>
433 <?php
434 $markup = ob_get_clean();
435 }
436 break;
437 case 'error':
438 if ( $required || $override ) {
439 ob_start();
440 ?>
441 <div class="srfm-error-message" data-srfm-id="srfm-error-<?php echo esc_attr( $block_id ); ?>" data-error-msg="<?php echo esc_attr( $error_msg ); ?>"<?php echo $duplicate_msg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
442 <?php echo esc_html( $error_msg ); ?>
443 </div>
444 <?php
445 $markup = ob_get_clean();
446 }
447 break;
448 case 'is_unique':
449 if ( $is_unique ) {
450 ob_start();
451 ?>
452 <div class="srfm-error">
453 <?php echo esc_html( $duplicate_msg ); ?>
454 </div>
455 <?php
456 $markup = ob_get_clean();
457 }
458 break;
459 case 'placeholder':
460 $markup = $label && '1' === $show_labels_as_placeholder ? esc_html( $label ) . ( $required ? esc_attr( $required_sign ) : '' ) : '';
461 break;
462 case 'label_text':
463 // This has been added for generating label text for the form markup instead of adding it in the label tag.
464 if ( $label ) {
465 ob_start();
466 ?>
467 <?php echo esc_html( $label ); ?>
468 <?php if ( $required ) { ?>
469 <span class="srfm-required" aria-hidden="true"> *</span>
470 <?php } ?>
471 <?php
472 $markup = ob_get_clean();
473 }
474 break;
475 default:
476 $markup = '';
477 }
478
479 return is_string( $markup ) ? $markup : '';
480 }
481
482 /**
483 * Get an SVG Icon
484 *
485 * @since 0.0.1
486 * @param string $icon the icon name.
487 * @param string $class if the baseline class should be added.
488 * @param string $html Custom attributes inside svg wrapper.
489 * @return string
490 */
491 public static function fetch_svg( $icon = '', $class = '', $html = '' ) {
492 $class = $class ? ' ' . $class : '';
493
494 if ( ! self::$srfm_svgs ) {
495 ob_start();
496
497 include_once SRFM_DIR . 'assets/svg/svgs.json';
498 self::$srfm_svgs = json_decode( self::get_string_value( ob_get_clean() ), true );
499 self::$srfm_svgs = apply_filters( 'srfm_svg_icons', self::$srfm_svgs );
500 }
501
502 ob_start();
503 ?>
504 <span class="srfm-icon<?php echo esc_attr( $class ); ?>" <?php echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
505 <?php echo self::$srfm_svgs[ $icon ] ?? ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
506 </span>
507 <?php
508 $output = ob_get_clean();
509 return is_string( $output ) ? $output : '';
510 }
511
512 /**
513 * Encrypt data using base64.
514 *
515 * @param string $input The input string which needs to be encrypted.
516 * @since 0.0.1
517 * @return string The encrypted string.
518 */
519 public static function encrypt( $input ) {
520 // If the input is empty or not a string, then abandon ship.
521 if ( empty( $input ) || ! is_string( $input ) ) {
522 return '';
523 }
524
525 // Strip HTML tags to prevent them from being included in IDs and field names.
526 $input = wp_strip_all_tags( $input );
527
528 // Encrypt the input and return it.
529 $base_64 = base64_encode( $input ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
530 return rtrim( $base_64, '=' );
531 }
532
533 /**
534 * Decrypt data using base64.
535 *
536 * @param string $input The input string which needs to be decrypted.
537 * @since 0.0.1
538 * @return string The decrypted string.
539 */
540 public static function decrypt( $input ) {
541 // If the input is empty or not a string, then abandon ship.
542 if ( empty( $input ) || ! is_string( $input ) ) {
543 return '';
544 }
545
546 // Decrypt the input and return it.
547 $base_64 = $input . str_repeat( '=', strlen( $input ) % 4 );
548 return base64_decode( $base_64 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
549 }
550
551 /**
552 * Update an option from the database.
553 *
554 * @param string $key The option key.
555 * @param mixed $value The value to update.
556 * @param bool $network_override Whether to allow the network_override admin setting to be overridden on subsites.
557 * @since 0.0.1
558 * @return bool True if the option was updated, false otherwise.
559 */
560 public static function update_admin_settings_option( $key, $value, $network_override = false ) {
561 // Update the site-wide option if we're in the network admin, and return the updated status.
562 return $network_override && is_multisite() ? update_site_option( $key, $value ) : update_option( $key, $value );
563 }
564
565 /**
566 * Update an option from the database.
567 *
568 * @param int|string $post_id post id / form id.
569 * @param string $key meta key name.
570 * @param bool $single single or multiple.
571 * @param mixed $default default value.
572 *
573 * @since 0.0.1
574 * @return string Meta value.
575 */
576 public static function get_meta_value( $post_id, $key, $single = true, $default = '' ) {
577 $srfm_live_mode_data = self::get_instant_form_live_data();
578
579 if ( isset( $srfm_live_mode_data[ $key ] ) ) {
580 // Give priority to live mode data if we have one set from the Instant Form.
581 return self::get_string_value( $srfm_live_mode_data[ $key ] );
582 }
583
584 return get_post_meta( self::get_integer_value( $post_id ), $key, $single ) ? self::get_string_value( get_post_meta( self::get_integer_value( $post_id ), $key, $single ) ) : self::get_string_value( $default );
585 }
586
587 /**
588 * Wrapper for the WordPress's get_post_meta function with the support for default values.
589 *
590 * @param int|string $post_id Post ID.
591 * @param string $key The meta key to retrieve.
592 * @param mixed $default Default value.
593 * @param bool $single Optional. Whether to return a single value.
594 * @since 0.0.8
595 * @return mixed Meta value.
596 */
597 public static function get_post_meta( $post_id, $key, $default = null, $single = true ) {
598 $meta_value = get_post_meta( self::get_integer_value( $post_id ), $key, $single );
599 return $meta_value ? $meta_value : $default;
600 }
601
602 /**
603 * Returns query params data for instant form live preview.
604 *
605 * @since 0.0.8
606 * @return array<mixed> Live preview data.
607 */
608 public static function get_instant_form_live_data() {
609 $srfm_live_mode_data = isset( $_GET['live_mode'] ) && self::current_user_can() ? self::sanitize_recursively( 'sanitize_text_field', wp_unslash( $_GET ) ) : []; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not needed here.
610
611 return $srfm_live_mode_data ? array_map(
612 // Normalize falsy values.
613 static function( $live_data ) {
614 return 'false' === $live_data ? false : $live_data;
615 },
616 $srfm_live_mode_data
617 ) : [];
618 }
619
620 /**
621 * Default dynamic block value.
622 *
623 * @since 0.0.1
624 * @return array<string> Meta value.
625 */
626 public static function default_dynamic_block_option() {
627
628 $common_err_msg = self::get_common_err_msg();
629
630 $default_values = [
631 'srfm_url_block_required_text' => $common_err_msg['required'],
632 'srfm_input_block_required_text' => $common_err_msg['required'],
633 'srfm_input_block_unique_text' => $common_err_msg['unique'],
634 'srfm_address_block_required_text' => $common_err_msg['required'],
635 'srfm_phone_block_required_text' => $common_err_msg['required'],
636 'srfm_phone_block_unique_text' => $common_err_msg['unique'],
637 'srfm_number_block_required_text' => $common_err_msg['required'],
638 'srfm_textarea_block_required_text' => $common_err_msg['required'],
639 'srfm_multi_choice_block_required_text' => $common_err_msg['required'],
640 'srfm_checkbox_block_required_text' => $common_err_msg['required'],
641 'srfm_gdpr_block_required_text' => $common_err_msg['required'],
642 'srfm_email_block_required_text' => $common_err_msg['required'],
643 'srfm_email_block_unique_text' => $common_err_msg['unique'],
644 'srfm_dropdown_block_required_text' => $common_err_msg['required'],
645 'srfm_rating_block_required_text' => $common_err_msg['required'],
646 ];
647
648 $default_values = array_merge( $default_values, Translatable::dynamic_validation_messages() );
649
650 return apply_filters( 'srfm_default_dynamic_block_option', $default_values, $common_err_msg );
651 }
652
653 /**
654 * Get default dynamic block value.
655 *
656 * @param string $key meta key name.
657 * @since 0.0.1
658 * @return string Meta value.
659 */
660 public static function get_default_dynamic_block_option( $key ) {
661 $default_dynamic_values = self::default_dynamic_block_option();
662 $option = get_option( 'srfm_default_dynamic_block_option', $default_dynamic_values );
663
664 if ( is_array( $option ) && array_key_exists( $key, $option ) ) {
665 return $option[ $key ];
666 }
667 return '';
668 }
669
670 /**
671 * Checks whether a given request has appropriate permissions.
672 *
673 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
674 * @since 0.0.1
675 */
676 public static function get_items_permissions_check() {
677 if ( self::current_user_can() ) {
678 return true;
679 }
680
681 return new WP_Error(
682 'rest_cannot_view',
683 __( 'Sorry, you are not allowed to perform this action.', 'sureforms' ),
684 [ 'status' => \rest_authorization_required_code() ]
685 );
686 }
687
688 /**
689 * Check if the current user has a given capability.
690 *
691 * @param string $capability The capability to check.
692 * @param array<mixed> $args Optional. Additional arguments to pass to the capability check.
693 *
694 * @since 0.0.3
695 * @return bool Whether the current user has the given capability or role.
696 */
697 public static function current_user_can( $capability = '', $args = [] ) {
698 if ( ! function_exists( 'current_user_can' ) ) {
699 return false;
700 }
701
702 if ( ! is_string( $capability ) || empty( $capability ) ) {
703 $capability = 'manage_options';
704 }
705
706 return ! empty( $args ) && is_array( $args ) && count( $args ) > 0
707 ? current_user_can( $capability, ...$args )
708 : current_user_can( $capability );
709 }
710
711 /**
712 * Get all the entries for the given form ids. The entries are older than the given days_old.
713 *
714 * @param int $days_old The number of days old the entries should be.
715 * @param array<int> $sf_form_ids The form ids for which the entries need to be fetched.
716 * @since 0.0.2
717 * @return array<mixed> the entries matching the criteria.
718 */
719 public static function get_entries_from_form_ids( $days_old = 0, $sf_form_ids = [] ) {
720
721 $entries = [];
722 $days_old_date = ( new \DateTime() )->modify( "-{$days_old} days" )->format( 'Y-m-d H:i:s' );
723
724 foreach ( $sf_form_ids as $form_id ) {
725 // args according to the get_all() function in the Entries class.
726 $args = [
727 'where' => [
728 [
729 [
730 'key' => 'form_id',
731 'value' => $form_id,
732 'compare' => '=',
733 ],
734 [
735 'key' => 'created_at',
736 'value' => $days_old_date,
737 'compare' => '<=',
738 ],
739 ],
740 ],
741 ];
742
743 // store all the entries in a single array.
744 $entries = array_merge( $entries, Entries::get_all( $args, false ) );
745 }
746 return $entries;
747 }
748
749 /**
750 * Decode block attributes.
751 * The function reverses the effect of serialize_block_attributes()
752 *
753 * @link https://developer.wordpress.org/reference/functions/serialize_block_attributes/
754 * @param string $encoded_data the encoded block attribute.
755 * @since 0.0.2
756 * @return string decoded block attribute
757 */
758 public static function decode_block_attribute( $encoded_data = '' ) {
759 $decoded_data = preg_replace( '/\\\\u002d\\\\u002d/', '--', self::get_string_value( $encoded_data ) );
760 $decoded_data = preg_replace( '/\\\\u003c/', '<', self::get_string_value( $decoded_data ) );
761 $decoded_data = preg_replace( '/\\\\u003e/', '>', self::get_string_value( $decoded_data ) );
762 $decoded_data = preg_replace( '/\\\\u0026/', '&', self::get_string_value( $decoded_data ) );
763 $decoded_data = preg_replace( '/\\\\\\\\"/', '"', self::get_string_value( $decoded_data ) );
764 return self::get_string_value( $decoded_data );
765 }
766
767 /**
768 * Map slugs to submission data.
769 *
770 * @param array<mixed> $submission_data submission_data.
771 * @since 0.0.3
772 * @return array<mixed>
773 */
774 public static function map_slug_to_submission_data( $submission_data = [] ) {
775 $mapped_data = [];
776 foreach ( $submission_data as $key => $value ) {
777 if ( false === strpos( $key, '-lbl-' ) ) {
778 continue;
779 }
780 $label = explode( '-lbl-', $key )[1];
781 $slug = implode( '-', array_slice( explode( '-', $label ), 1 ) );
782 $slug = str_replace( ' ', '_', $slug );
783
784 /**
785 * Filters whether a field should be skipped when mapping slugs to submission data.
786 *
787 * This filter allows plugins or custom code to determine if a field should be excluded
788 * from the mapped submission data array (such as for internal fields or extraneous meta).
789 *
790 * @since 2.0.0
791 *
792 * @param bool $skip_this_field Whether to skip this field from processing. Default false.
793 * @param array $args {
794 * Arguments used for this field.
795 *
796 * @type string $key The original key of the field in the submission data array.
797 * @type string $slug The mapped slug parsed from the field key.
798 * @type mixed $value The value assigned to this field.
799 * }
800 */
801 $skip_this_field = apply_filters(
802 'srfm_map_slug_to_submission_data_should_skip',
803 false,
804 [
805 'key' => $key,
806 'slug' => $slug,
807 'value' => $value,
808 ]
809 );
810
811 if ( $skip_this_field ) {
812 continue;
813 }
814
815 // Check if value is array to handle external package field functionality.
816 // like repeater fields that need special processing.
817 if ( is_array( $value ) && ! empty( $value ) ) {
818 // Apply filter to allow external packages to process array values.
819 // Returns processed data with 'is_processed' flag if successfully handled.
820 $filtered_submission_data = apply_filters(
821 'srfm_map_slug_to_submission_data_array',
822 [
823 'value' => $value,
824 'key' => $key,
825 'slug' => $slug,
826 ]
827 );
828 if ( isset( $filtered_submission_data['is_processed'] ) && true === $filtered_submission_data['is_processed'] ) {
829 $mapped_data[ $slug ] = $filtered_submission_data['value'];
830 continue;
831 }
832 }
833
834 // If the value is an array (e.g. multi-upload field), decode each URL value.
835 if ( is_array( $value ) ) {
836 $mapped_data[ $slug ] = array_map(
837 static function ( $val ) {
838 return is_string( $val ) ? rawurldecode( $val ) : $val;
839 },
840 $value
841 );
842 continue;
843 }
844
845 $mapped_data[ $slug ] = is_string( $value ) ? html_entity_decode( esc_attr( $value ) ) : $value;
846 }
847 return $mapped_data;
848 }
849
850 /**
851 * Get forms options. Shows all the available forms in the dropdown.
852 *
853 * @since 0.0.5
854 * @param string $key Determines the type of data to return.
855 * @return array<mixed>
856 */
857 public static function get_sureforms( $key = '' ) {
858 $forms = get_posts(
859 apply_filters(
860 'srfm_get_sureforms_query_args',
861 [
862 'post_type' => SRFM_FORMS_POST_TYPE,
863 'posts_per_page' => -1,
864 'post_status' => 'publish',
865 ]
866 )
867 );
868
869 $options = [];
870
871 foreach ( $forms as $form ) {
872 if ( $form instanceof WP_Post ) {
873 if ( 'all' === $key ) {
874 $options[ $form->ID ] = $form;
875 } elseif ( ! empty( $key ) && is_string( $key ) && isset( $form->$key ) ) {
876 $options[ $form->ID ] = $form->$key;
877 } else {
878 $options[ $form->ID ] = $form->post_title;
879 }
880 }
881 }
882
883 return $options;
884 }
885
886 /**
887 * Get all the forms.
888 *
889 * @since 0.0.5
890 * @return array<mixed>
891 */
892 public static function get_sureforms_title_with_ids() {
893 $form_options = self::get_sureforms();
894
895 foreach ( $form_options as $key => $value ) {
896 $form_options[ $key ] = $value . ' #' . $key;
897 }
898
899 return $form_options;
900 }
901
902 /**
903 * Get the CSS variables based on different field spacing sizes.
904 *
905 * @param string|null $field_spacing The field spacing size or boolean false to return complete sizes array.
906 *
907 * @since 0.0.7
908 * @return array<string|mixed>
909 */
910 public static function get_css_vars( $field_spacing = null ) {
911 /**
912 * $sizes - Field Spacing Sizes Variables.
913 * The array contains the CSS variables for different field spacing sizes.
914 * Each key corresponds to the field spacing size, and the value is an array of CSS variables.
915 *
916 * For future variables depending on the field spacing size, add the variable to the array respectively.
917 */
918 $sizes = apply_filters(
919 'srfm_css_vars_sizes',
920 [
921 'small' => [
922 '--srfm-row-gap-between-blocks' => '16px',
923 // Address block gap and spacing variables.
924 '--srfm-address-label-font-size' => '14px',
925 '--srfm-address-label-line-height' => '20px',
926 '--srfm-address-description-font-size' => '12px',
927 '--srfm-address-description-line-height' => '16px',
928 '--srfm-col-gap-between-fields' => '12px',
929 '--srfm-row-gap-between-fields' => '12px',
930 '--srfm-gap-below-address-label' => '12px',
931 // Dropdown Variables.
932 '--srfm-dropdown-font-size' => '14px',
933 '--srfm-dropdown-gap-between-input-menu' => '4px',
934 '--srfm-dropdown-badge-padding' => '2px 6px',
935 '--srfm-dropdown-multiselect-font-size' => '12px',
936 '--srfm-dropdown-multiselect-line-height' => '16px',
937 '--srfm-dropdown-padding-right' => '12px',
938 // initial padding and from 20px - 12px for dropdown arrow width and 8px for gap before dropdown arrow.
939 '--srfm-dropdown-padding-right-icon' => 'calc( var( --srfm-dropdown-padding-right ) + 20px )',
940 '--srfm-dropdown-multiselect-padding' => '8px var( --srfm-dropdown-padding-right-icon ) 8px 8px',
941 // Input Field Variables.
942 '--srfm-input-height' => '40px',
943 '--srfm-input-field-padding' => '10px 12px',
944 '--srfm-input-field-font-size' => '14px',
945 '--srfm-input-field-line-height' => '20px',
946 '--srfm-input-field-margin-top' => '4px',
947 '--srfm-input-field-margin-bottom' => '4px',
948 // Checkbox and GDPR Variables.
949 '--srfm-checkbox-label-font-size' => '14px',
950 '--srfm-checkbox-label-line-height' => '20px',
951 '--srfm-checkbox-description-font-size' => '12px',
952 '--srfm-checkbox-description-line-height' => '16px',
953 '--srfm-check-ctn-width' => '16px',
954 '--srfm-check-ctn-height' => '16px',
955 '--srfm-check-svg-size' => '10px',
956 '--srfm-checkbox-margin-top-frontend' => '2px',
957 '--srfm-checkbox-margin-top-editor' => '3px',
958 '--srfm-check-gap' => '8px',
959 '--srfm-checkbox-description-margin-left' => '24px',
960 // Phone Number field variables.
961 '--srfm-flag-section-padding' => '10px 0 10px 12px',
962 '--srfm-gap-between-icon-text' => '8px',
963 // Label Variables.
964 '--srfm-label-font-size' => '14px',
965 '--srfm-label-line-height' => '20px',
966 // Description Variables.
967 '--srfm-description-font-size' => '12px',
968 '--srfm-description-line-height' => '16px',
969 // Button Variables.
970 '--srfm-btn-padding' => '8px 14px',
971 '--srfm-btn-font-size' => '14px',
972 '--srfm-btn-line-height' => '20px',
973 // Multi Choice Variables.
974 '--srfm-multi-choice-horizontal-padding' => '16px',
975 '--srfm-multi-choice-vertical-padding' => '16px',
976 '--srfm-multi-choice-internal-option-gap' => '8px',
977 '--srfm-multi-choice-vertical-svg-size' => '32px',
978 '--srfm-multi-choice-horizontal-image-size' => '20px',
979 '--srfm-multi-choice-vertical-image-size' => '100px',
980 '--srfm-multi-choice-outer-padding' => '0',
981 ],
982 'medium' => [
983 '--srfm-row-gap-between-blocks' => '18px',
984 // Address block gap and spacing variables.
985 '--srfm-address-label-font-size' => '16px',
986 '--srfm-address-label-line-height' => '24px',
987 '--srfm-address-description-font-size' => '14px',
988 '--srfm-address-description-line-height' => '20px',
989 '--srfm-col-gap-between-fields' => '16px',
990 '--srfm-row-gap-between-fields' => '16px',
991 '--srfm-gap-below-address-label' => '14px',
992 // Input Field Variables.
993 '--srfm-input-height' => '44px',
994 '--srfm-input-field-font-size' => '16px',
995 '--srfm-input-field-line-height' => '24px',
996 '--srfm-input-field-margin-top' => '6px',
997 '--srfm-input-field-margin-bottom' => '6px',
998 // Checkbox and GDPR Variables.
999 '--srfm-checkbox-label-font-size' => '16px',
1000 '--srfm-checkbox-label-line-height' => '24px',
1001 '--srfm-checkbox-description-font-size' => '14px',
1002 '--srfm-checkbox-description-line-height' => '20px',
1003 '--srfm-checkbox-margin-top-frontend' => '4px',
1004 '--srfm-checkbox-margin-top-editor' => '6px',
1005 '--srfm-checkbox-description-margin-left' => '24px',
1006 // Label Variables.
1007 '--srfm-label-font-size' => '16px',
1008 '--srfm-label-line-height' => '24px',
1009 // Description Variables.
1010 '--srfm-description-font-size' => '14px',
1011 '--srfm-description-line-height' => '20px',
1012 // Button Variables.
1013 '--srfm-btn-padding' => '10px 14px',
1014 '--srfm-btn-font-size' => '16px',
1015 '--srfm-btn-line-height' => '24px',
1016 // Multi Choice Variables.
1017 '--srfm-multi-choice-horizontal-padding' => '20px',
1018 '--srfm-multi-choice-vertical-padding' => '20px',
1019 '--srfm-multi-choice-vertical-svg-size' => '40px',
1020 '--srfm-multi-choice-horizontal-image-size' => '24px',
1021 '--srfm-multi-choice-vertical-image-size' => '120px',
1022 '--srfm-multi-choice-outer-padding' => '2px',
1023 ],
1024 'large' => [
1025 '--srfm-row-gap-between-blocks' => '20px',
1026 // Address Block Gap and Spacing Variables.
1027 '--srfm-address-label-font-size' => '18px',
1028 '--srfm-address-label-line-height' => '28px',
1029 '--srfm-address-description-font-size' => '16px',
1030 '--srfm-address-description-line-height' => '24px',
1031 '--srfm-col-gap-between-fields' => '16px',
1032 '--srfm-row-gap-between-fields' => '20px',
1033 '--srfm-gap-below-address-label' => '16px',
1034 // Dropdown Variables.
1035 '--srfm-dropdown-font-size' => '16px',
1036 '--srfm-dropdown-gap-between-input-menu' => '6px',
1037 '--srfm-dropdown-badge-padding' => '6px 6px',
1038 '--srfm-dropdown-multiselect-font-size' => '14px',
1039 '--srfm-dropdown-multiselect-line-height' => '20px',
1040 '--srfm-dropdown-padding-right' => '14px',
1041 // Input Field Variables.
1042 '--srfm-input-height' => '48px',
1043 '--srfm-input-field-padding' => '10px 14px',
1044 '--srfm-input-field-font-size' => '18px',
1045 '--srfm-input-field-line-height' => '28px',
1046 '--srfm-input-field-margin-top' => '8px',
1047 '--srfm-input-field-margin-bottom' => '8px',
1048 // Checkbox and GDPR Variables.
1049 '--srfm-checkbox-label-font-size' => '18px',
1050 '--srfm-checkbox-label-line-height' => '28px',
1051 '--srfm-checkbox-description-font-size' => '16px',
1052 '--srfm-checkbox-description-line-height' => '24px',
1053 '--srfm-check-ctn-width' => '20px',
1054 '--srfm-check-ctn-height' => '20px',
1055 '--srfm-check-svg-size' => '14px',
1056 '--srfm-check-gap' => '10px',
1057 '--srfm-checkbox-margin-top-frontend' => '4px',
1058 '--srfm-checkbox-margin-top-editor' => '5px',
1059 '--srfm-checkbox-description-margin-left' => '30px',
1060 // Label Variables.
1061 '--srfm-label-font-size' => '18px',
1062 '--srfm-label-line-height' => '28px',
1063 // Description Variables.
1064 '--srfm-description-font-size' => '16px',
1065 '--srfm-description-line-height' => '24px',
1066 // Button Variables.
1067 '--srfm-btn-padding' => '10px 14px',
1068 '--srfm-btn-font-size' => '18px',
1069 '--srfm-btn-line-height' => '28px',
1070 // Multi Choice Variables.
1071 '--srfm-multi-choice-horizontal-padding' => '24px',
1072 '--srfm-multi-choice-vertical-padding' => '24px',
1073 '--srfm-multi-choice-internal-option-gap' => '12px',
1074 '--srfm-multi-choice-vertical-svg-size' => '48px',
1075 '--srfm-multi-choice-horizontal-image-size' => '28px',
1076 '--srfm-multi-choice-vertical-image-size' => '140px',
1077 '--srfm-multi-choice-outer-padding' => '4px',
1078 ],
1079 ]
1080 );
1081 // Return complete sizes array if field_spacing is false. Required in case of JS for Editor changes.
1082 if ( ! $field_spacing ) {
1083 return $sizes;
1084 }
1085
1086 $selected_size = $sizes['small'];
1087 if ( 'small' !== $field_spacing && isset( $sizes[ $field_spacing ] ) ) {
1088 $selected_size = array_merge( $selected_size, $sizes[ $field_spacing ] );
1089 }
1090
1091 return $selected_size;
1092 }
1093
1094 /**
1095 * Array of SureForms blocks which get have user input.
1096 *
1097 * @since 0.0.10
1098 * @return array<string>
1099 */
1100 public static function get_sureforms_blocks() {
1101 return apply_filters(
1102 'srfm_blocks',
1103 [
1104 'srfm/input',
1105 'srfm/email',
1106 'srfm/textarea',
1107 'srfm/number',
1108 'srfm/checkbox',
1109 'srfm/gdpr',
1110 'srfm/phone',
1111 'srfm/address',
1112 'srfm/dropdown',
1113 'srfm/multi-choice',
1114 'srfm/radio',
1115 'srfm/submit',
1116 'srfm/url',
1117 'srfm/payment',
1118 ]
1119 );
1120 }
1121
1122 /**
1123 * Render a site key missing error message.
1124 *
1125 * @param string $provider_name Name of the captcha provider (e.g., HCaptcha, Google reCAPTCHA, Turnstile).
1126 * @since 1.7.0
1127 * @since 1.7.1 moved to inc/helper.php from inc/generate-form-markup.php
1128 * @return void
1129 */
1130 public static function render_missing_sitekey_error( $provider_name ) {
1131 $icon = self::fetch_svg( 'info_circle', '', 'aria-hidden="true"' );
1132 ?>
1133 <p id="sitekey-error" class="srfm-common-error-message srfm-error-message">
1134 <?php echo wp_kses( $icon, self::$allowed_tags_svg ); ?>
1135 <span class="srfm-error-content">
1136 <?php
1137 echo esc_html(
1138 sprintf(
1139 /* translators: %s: Provider name like HCaptcha, Google reCAPTCHA, Turnstile */
1140 __( '%s sitekey is missing. Please contact your site administrator.', 'sureforms' ),
1141 $provider_name
1142 )
1143 );
1144 ?>
1145 </span>
1146 </p>
1147 <?php
1148 }
1149
1150 /**
1151 * Parse and sanitize an email list string which may contain:
1152 *
1153 * @param string $input email addresses.
1154 * @since 1.13.2
1155 * @return string Sanitized email header string.
1156 */
1157 public static function sanitize_email_header( $input ) {
1158 if ( empty( $input ) ) {
1159 return '';
1160 }
1161
1162 $parts = explode( ',', $input );
1163 $output = [];
1164
1165 foreach ( $parts as $part ) {
1166 $part = trim( $part );
1167
1168 // Match "Name <email>".
1169 if ( preg_match( '/^(.*)<(.+)>$/', $part, $matches ) ) {
1170 $name = trim( $matches[1], "\" \t\n\r\0\x0B" ); // trim quotes.
1171 $email = sanitize_email( trim( $matches[2] ) );
1172
1173 if ( is_email( $email ) ) {
1174 $safe_name = sanitize_text_field( $name );
1175 $output[] = $safe_name . ' <' . $email . '>';
1176 }
1177 } else {
1178 // Plain email case.
1179 $email = sanitize_email( $part );
1180 if ( is_email( $email ) ) {
1181 $output[] = $email;
1182 }
1183 }
1184 }
1185
1186 return ! empty( $output ) ? implode( ', ', $output ) : '';
1187 }
1188
1189 /**
1190 * Process blocks and inner blocks.
1191 *
1192 * @param array<mixed> $blocks The block data.
1193 * @param array<string> $slugs The array of existing slugs.
1194 * @param bool $updated The array of existing slugs.
1195 * @param string $prefix The array of existing slugs.
1196 * @param bool $skip_checking_existing_slug Skips the checking of existing slug if passed true. More information documented inside this function.
1197 * @since 0.0.10
1198 * @return array
1199 */
1200 public static function process_blocks( $blocks, &$slugs, &$updated, $prefix = '', $skip_checking_existing_slug = false ) {
1201
1202 if ( ! is_array( $blocks ) ) {
1203 return [ $blocks, $slugs, $updated ];
1204 }
1205
1206 foreach ( $blocks as $index => $block ) {
1207
1208 if ( ! is_array( $block ) ) {
1209 continue;
1210 }
1211 // Checking only for SureForms blocks which can have user input.
1212 if ( empty( $block['blockName'] ) || ! in_array( $block['blockName'], self::get_sureforms_blocks(), true ) ) {
1213 continue;
1214 }
1215
1216 /**
1217 * Lets continue if slug already exists.
1218 * This will ensure that we don't update already existing slugs.
1219 */
1220 if ( isset( $block['attrs'] ) && ! empty( $block['attrs']['slug'] ) && ! in_array( $block['attrs']['slug'], $slugs, true ) ) {
1221
1222 // Made it associative array, so that we can directly check it using block_id rather than mapping or using "in_array" for the checks.
1223 $slugs[ $block['attrs']['block_id'] ] = self::get_string_value( $block['attrs']['slug'] );
1224
1225 if ( is_array( $block['innerBlocks'] ) && ! empty( $block['innerBlocks'] ) ) {
1226 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, '' );
1227 }
1228 continue;
1229 }
1230
1231 if ( $skip_checking_existing_slug && empty( $block['innerBlocks'] ) && isset( $slugs[ $block['attrs']['block_id'] ] ) ) {
1232 /**
1233 * Skip re-processing of the already process or existing slugs if above parameter "$skip_checking_existing_slug" is passed as true.
1234 * This is helpful in the scenarios where we need to compare and verify between already saved blocks and new unsaved blocks parsed
1235 * from the contents.
1236 *
1237 * However, it is also necessary to make sure if that current block is not a parent / wrapper block
1238 * by checking "$block['innerBlocks']" empty.
1239 *
1240 * And finally, checking if the block-id "$block['attrs']['block_id']" is already set in the list of "$slugs",
1241 * making sure that we are only processing the new blocks.
1242 */
1243 continue;
1244 }
1245
1246 if ( is_array( $blocks[ $index ]['attrs'] ) ) {
1247
1248 $blocks[ $index ]['attrs']['slug'] = self::generate_unique_block_slug( $block, $slugs, $prefix );
1249 $slugs[ $block['attrs']['block_id'] ] = $blocks[ $index ]['attrs']['slug']; // Made it associative array, so that we can directly check it using block_id rather than mapping or using "in_array" for the checks.
1250 $updated = true;
1251 if ( is_array( $block['innerBlocks'] ) && ! empty( $block['innerBlocks'] ) ) {
1252
1253 [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $blocks[ $index ]['attrs']['slug'] );
1254
1255 }
1256 }
1257 }
1258 return [ $blocks, $slugs, $updated ];
1259 }
1260
1261 /**
1262 * Generates slug based on the provided block and existing slugs.
1263 *
1264 * @param array<mixed> $block The block data.
1265 * @param array<string> $slugs The array of existing slugs.
1266 * @param string $prefix The array of existing slugs.
1267 * @since 0.0.10
1268 * @return string The generated unique block slug.
1269 */
1270 public static function generate_unique_block_slug( $block, $slugs, $prefix ) {
1271 $slug = is_string( $block['blockName'] ) ? $block['blockName'] : '';
1272
1273 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
1274 $slug = sanitize_title( $block['attrs']['label'] );
1275
1276 // If the label contains non-Latin characters (e.g. Japanese, Chinese),
1277 // sanitize_title() produces a percent-encoded slug like "%e3%83%95%e3%83%aa".
1278 // These are unstable and break conditional logic field matching.
1279 // Fall back to the block name to ensure a stable ASCII slug.
1280 if ( false !== strpos( $slug, '%' ) ) {
1281 $block_name = is_string( $block['blockName'] ) ? $block['blockName'] : '';
1282 // Strip the 'srfm/' namespace to match JS-side cleanForSlug() output.
1283 $block_name = (string) preg_replace( '/^srfm\//', '', $block_name );
1284 $slug = sanitize_title( $block_name );
1285 }
1286 }
1287
1288 if ( ! empty( $prefix ) ) {
1289 $slug = $prefix . '-' . $slug;
1290 }
1291
1292 return self::generate_slug( $slug, $slugs );
1293 }
1294
1295 /**
1296 * This function ensures that the slug is unique.
1297 * If the slug is already taken, it appends a number to the slug to make it unique.
1298 *
1299 * @param string $slug test to be converted to slug.
1300 * @param array<string> $slugs An array of existing slugs.
1301 * @since 0.0.10
1302 * @return string The unique slug.
1303 */
1304 public static function generate_slug( $slug, $slugs ) {
1305 $slug = sanitize_title( $slug );
1306
1307 if ( ! in_array( $slug, $slugs, true ) ) {
1308 return $slug;
1309 }
1310
1311 $index = 1;
1312
1313 while ( in_array( $slug . '-' . $index, $slugs, true ) ) {
1314 $index++;
1315 }
1316
1317 return $slug . '-' . $index;
1318 }
1319
1320 /**
1321 * Encode data to JSON. This function will encode the data with JSON_UNESCAPED_SLASHES and JSON_UNESCAPED_UNICODE.
1322 *
1323 * @since 0.0.11
1324 * @param array<mixed> $data The data to encode.
1325 * @return string|false The JSON representation of the value on success or false on failure.
1326 */
1327 public static function encode_json( $data ) {
1328 return wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
1329 }
1330
1331 /**
1332 * Returns true if SureTriggers plugin is ready for the custom app.
1333 *
1334 * @since 1.0.3
1335 * @return bool Returns true if SureTriggers plugin is ready for the custom app.
1336 */
1337 public static function is_suretriggers_ready() {
1338 if ( ! defined( 'SURE_TRIGGERS_FILE' ) ) {
1339 // Probably plugin is de-activated or not installed at all.
1340 return false;
1341 }
1342
1343 $suretriggers_data = get_option( 'suretrigger_options', [] );
1344 if ( ! is_array( $suretriggers_data ) || empty( $suretriggers_data['secret_key'] ) || ! is_string( $suretriggers_data['secret_key'] ) ) {
1345 // SureTriggers is not authenticated yet.
1346 return false;
1347 }
1348
1349 return true;
1350 }
1351
1352 /**
1353 * Registers script translations for a specific handle.
1354 *
1355 * This function sets the script translations for a given script handle, allowing
1356 * localization of JavaScript strings using the specified text domain and path.
1357 *
1358 * @param string $handle The script handle to apply translations to.
1359 * @param string $domain Optional. The text domain for translations. Default is 'sureforms'.
1360 * @param string $path Optional. The path to the translation files. Default is the 'languages' folder in the SureForms directory.
1361 *
1362 * @since 1.0.5
1363 * @return void
1364 */
1365 public static function register_script_translations( $handle, $domain = 'sureforms', $path = SRFM_DIR . 'languages' ) {
1366 wp_set_script_translations( $handle, $domain, $path );
1367 }
1368
1369 /**
1370 * Validates whether the specified conditions or a single key-value pair exist in the request context.
1371 *
1372 * - If `$conditions` is provided as an array, it will validate all key-value pairs in `$conditions`
1373 * against the `$_REQUEST` superglobal.
1374 * - If `$conditions` is empty, it validates a single key-value pair from `$key` and `$value`.
1375 *
1376 * @param string $value The expected value to match in the request if `$conditions` is not used.
1377 * @param string $key The key to check for in the request if `$conditions` is not used.
1378 * @param array<string, string> $conditions An optional associative array of key-value pairs to validate.
1379 * @since 1.1.1
1380 * @return bool Returns true if all conditions are met or the single key-value pair is valid, otherwise false.
1381 */
1382 public static function validate_request_context( $value, $key = 'post_type', $conditions = [] ) {
1383 // If conditions are provided, validate all key-value pairs in the conditions array.
1384 if ( ! empty( $conditions ) ) {
1385 foreach ( $conditions as $condition_key => $condition_value ) {
1386 if ( ! isset( $_REQUEST[ $condition_key ] ) || $_REQUEST[ $condition_key ] !== $condition_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a controlled comparison of request values.
1387 // Return false if any condition is not satisfied.
1388 return false;
1389 }
1390 }
1391 // Return true if all conditions are satisfied.
1392 return true;
1393 }
1394
1395 // Validate $value and $key when no conditions are provided.
1396 if ( empty( $key ) || empty( $value ) ) {
1397 return false;
1398 }
1399
1400 // Validate a single key-value pair when no conditions are provided.
1401 return isset( $_REQUEST[ $key ] ) && $_REQUEST[ $key ] === $value; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not needed here. Input is validated via strict comparison.
1402 }
1403
1404 /**
1405 * Retrieve the list of excluded fields for form data processing.
1406 *
1407 * This method returns an array of field keys that should be excluded when
1408 * processing form data.
1409 *
1410 * @since 1.1.1
1411 * @return array<string> Returns the string array of excluded fields.
1412 */
1413 public static function get_excluded_fields() {
1414 $excluded_fields = [ 'srfm-honeypot-field', 'g-recaptcha-response', 'srfm-sender-email-field', 'form-id' ];
1415
1416 return apply_filters( 'srfm_excluded_fields', $excluded_fields );
1417 }
1418
1419 /**
1420 * Check whether the current page is a SureForms admin page.
1421 *
1422 * @since 1.2.2
1423 * @return bool Returns true if the current page is a SureForms admin page, otherwise false.
1424 */
1425 public static function is_sureforms_admin_page() {
1426 $current_screen = get_current_screen();
1427 $is_screen_sureforms_menu = self::validate_request_context( 'sureforms_menu', 'page' );
1428 $is_screen_add_new_form = self::validate_request_context( 'add-new-form', 'page' );
1429 $is_screen_sureforms_form_settings = self::validate_request_context( 'sureforms_form_settings', 'page' );
1430 $is_screen_sureforms_entries = self::validate_request_context( SRFM_ENTRIES, 'page' );
1431 $is_post_type_sureforms_form = $current_screen && SRFM_FORMS_POST_TYPE === $current_screen->post_type;
1432
1433 return $is_screen_sureforms_menu || $is_screen_add_new_form || $is_screen_sureforms_form_settings || $is_screen_sureforms_entries || $is_post_type_sureforms_form;
1434 }
1435
1436 /**
1437 * Filters and concatenates valid class names from an array.
1438 *
1439 * @param array<string> $class_names The array containing potential class names.
1440 * @since 1.4.0
1441 * @return string The concatenated string of valid class names separated by spaces.
1442 */
1443 public static function join_strings( $class_names ) {
1444 // Filter the array to include only valid class names.
1445 $valid_class_names = array_filter(
1446 $class_names,
1447 static function ( $value ) {
1448 return is_string( $value ) && '' !== $value && false !== $value;
1449 }
1450 );
1451
1452 // Concatenate the valid class names with spaces and return.
1453 return implode( ' ', $valid_class_names );
1454 }
1455 /**
1456 * Get SureForms Website URL.
1457 *
1458 * @param string $trail The URL trail to append to SureForms website URL. The parameter should not include a leading slash as the base URL already ends with a trailing slash.
1459 * @param array<string, string> $utm_args Optional. An associative array of UTM parameters to append to the URL. Default empty array. Example: [ 'utm_medium' => 'dashboard'].
1460 * @since 0.0.7
1461 * @return string
1462 */
1463 public static function get_sureforms_website_url( $trail, $utm_args = [] ) {
1464 $url = SRFM_WEBSITE;
1465 if ( ! empty( $trail ) && is_string( $trail ) ) {
1466 $url = SRFM_WEBSITE . $trail;
1467 }
1468
1469 if ( ! is_array( $utm_args ) ) {
1470 $utm_args = [];
1471 }
1472
1473 // SRFM-2709: deterministic UTM attribution — start.
1474 // When the caller opts into UTM tracking by passing any utm_args, fill in
1475 // SureForms' deterministic source/campaign defaults. Caller-provided keys
1476 // (including the placement passed via utm_medium) always win.
1477 if ( ! empty( $utm_args ) ) {
1478 $utm_args = array_merge(
1479 [
1480 'utm_source' => 'sureforms_plugin',
1481 'utm_campaign' => 'core_plugin',
1482 ],
1483 $utm_args
1484 );
1485 }
1486 // SRFM-2709: deterministic UTM attribution — end.
1487
1488 if ( class_exists( 'BSF_UTM_Analytics' ) ) {
1489 $url = \BSF_UTM_Analytics::get_utm_ready_link( $url, 'sureforms', $utm_args );
1490 }
1491
1492 // SRFM-2709: post-BSF_UTM_Analytics fallback — start.
1493 // BSF_UTM_Analytics returns the URL unchanged when no install referer is
1494 // recorded. Merge any caller UTM keys still missing from the final URL.
1495 if ( ! empty( $utm_args ) ) {
1496 $existing = [];
1497 $query = wp_parse_url( $url, PHP_URL_QUERY );
1498 if ( is_string( $query ) && '' !== $query ) {
1499 parse_str( $query, $existing );
1500 }
1501 $missing = array_diff_key( $utm_args, $existing );
1502 if ( ! empty( $missing ) ) {
1503 $url = add_query_arg( $missing, $url );
1504 }
1505 }
1506 // SRFM-2709: post-BSF_UTM_Analytics fallback — end.
1507
1508 return esc_url( $url );
1509 }
1510
1511 /**
1512 * Validates if the given string is a valid CSS class name.
1513 *
1514 * A valid CSS class name:
1515 * - Does not start with a digit, hyphen, or underscore.
1516 * - Can contain alphanumeric characters, underscores, hyphens, and Unicode letters.
1517 *
1518 * @param string $class_name The class name to validate.
1519 *
1520 * @since 1.3.1
1521 * @return bool True if the class name is valid, otherwise false.
1522 */
1523 public static function is_valid_css_class_name( $class_name ) {
1524 // Regular expression to validate a Unicode-aware CSS class name.
1525 $class_name_regex = '/^[^\d\-_][\w\p{L}\p{N}\-_]*$/u';
1526
1527 // Check if the className matches the pattern.
1528 return preg_match( $class_name_regex, $class_name ) === 1;
1529 }
1530
1531 /**
1532 * Get the gradient css for given gradient parameters.
1533 *
1534 * @param string $type The type of gradient. Default 'linear'.
1535 * @param string $color1 The first color of the gradient. Default '#FFC9B2'.
1536 * @param string $color2 The second color of the gradient. Default '#C7CBFF'.
1537 * @param int $loc1 The location of the first color. Default 0.
1538 * @param int $loc2 The location of the second color. Default 100.
1539 * @param int $angle The angle of the gradient. Default 90.
1540 *
1541 * @since 1.4.4
1542 * @return string The gradient css.
1543 */
1544 public static function get_gradient_css( $type = 'linear', $color1 = '#FFC9B2', $color2 = '#C7CBFF', $loc1 = 0, $loc2 = 100, $angle = 90 ) {
1545 if ( 'linear' === $type ) {
1546 return "linear-gradient({$angle}deg, {$color1} {$loc1}%, {$color2} {$loc2}%)";
1547 }
1548 return "radial-gradient({$color1} {$loc1}%, {$color2} {$loc2}%)";
1549 }
1550
1551 /**
1552 * Return the classes based on background and overlay type to add to the form container.
1553 *
1554 * @param string $background_type The background type.
1555 * @param string $overlay_type The overlay type.
1556 * @param string $bg_image The background image url.
1557 *
1558 * @since 1.4.4
1559 * @return string The classes to add to the form container.
1560 */
1561 public static function get_background_classes( $background_type, $overlay_type, $bg_image = '' ) {
1562 if ( empty( $background_type ) ) {
1563 $background_type = 'color';
1564 }
1565
1566 $background_type_class = '';
1567 $overlay_class = 'image' === $background_type && ! empty( $bg_image ) && $overlay_type ? "srfm-overlay-{$overlay_type}" : '';
1568
1569 // Set the class based on the background type.
1570 switch ( $background_type ) {
1571 case 'image':
1572 $background_type_class = 'srfm-bg-image';
1573 break;
1574 case 'gradient':
1575 $background_type_class = 'srfm-bg-gradient';
1576 break;
1577 default:
1578 $background_type_class = 'srfm-bg-color';
1579 break;
1580 }
1581
1582 return self::join_strings( [ $background_type_class, $overlay_class ] );
1583 }
1584
1585 /**
1586 * Custom escape function for the textarea with rich text support.
1587 *
1588 * @param string $content The content submitted by the user in the textarea block.
1589 * @since 1.7.1
1590 *
1591 * @return string Escaped content.
1592 */
1593 public static function esc_textarea( $content ) {
1594 $content = wpautop( self::sanitize_textarea( $content ) );
1595
1596 return trim( str_replace( [ "\r\n", "\r", "\n" ], '', $content ) );
1597 }
1598
1599 /**
1600 * Custom sanitization function for the textarea with rich text support.
1601 *
1602 * @param string $content The content submitted by the user in the textarea block.
1603 * @since 1.7.1
1604 *
1605 * @return string Sanitized content.
1606 */
1607 public static function sanitize_textarea( $content ) {
1608 $count = 1;
1609 $content = convert_invalid_entities( $content );
1610
1611 // Remove the 'script' and 'style' tags recursively from the content.
1612 while ( $count ) {
1613 $content = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', self::get_string_value( $content ), - 1, $count );
1614 }
1615
1616 // Disable the safe style attribute parsing for the textarea block.
1617 add_filter( 'safe_style_css', [ self::class, 'disable_style_attr_parsing' ], 10, 1 );
1618 $content = wp_kses_post( self::get_string_value( $content ) );
1619
1620 // Remove the filter after sanitization to avoid affecting other blocks.
1621 remove_filter( 'safe_style_css', [ self::class, 'disable_style_attr_parsing' ], 10 );
1622
1623 // Ensure all tags are balanced.
1624 return force_balance_tags( $content );
1625 }
1626
1627 /**
1628 * Disable parsing of style attributes for the textarea block.
1629 *
1630 * @param array<string> $allowed_styles The allowed styles.
1631 * @since 1.7.1
1632 *
1633 * @return array An empty array to disable style attribute parsing.
1634 */
1635 public static function disable_style_attr_parsing( $allowed_styles ) {
1636 unset( $allowed_styles );
1637 // Disable parsing of style attributes.
1638 return [];
1639 }
1640 /**
1641 * Strips JavaScript attributes from HTML content.
1642 *
1643 * @param string $html The HTML content to process.
1644 * @param bool $remove_link_target Optional. When true, removes target and strips noopener/noreferrer from rel on links. Default false.
1645 * @since 1.7.1
1646 * @since 2.5.2 Added $remove_link_target parameter.
1647 * @return string The cleaned HTML content without JavaScript attributes.
1648 */
1649 public static function strip_js_attributes( $html, $remove_link_target = false ) {
1650 $dom = new \DOMDocument();
1651
1652 // Suppress warnings due to malformed HTML.
1653 libxml_use_internal_errors( true );
1654 $loaded = $dom->loadHTML( '<?xml encoding="utf-8" ?>' . $html );
1655 libxml_clear_errors();
1656
1657 if ( ! $loaded ) {
1658 return $html; // Return original HTML if loading fails.
1659 }
1660
1661 $xpath = new \DOMXPath( $dom );
1662
1663 // 1. Remove all <script> tags.
1664 $script_nodes = $xpath->query( '//script' );
1665 if ( $script_nodes instanceof \DOMNodeList ) {
1666 foreach ( $script_nodes as $script ) {
1667 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- This is a DOM element.
1668 $parent_node = $script->parentNode;
1669 if ( $parent_node instanceof \DOMNode ) {
1670 $parent_node->removeChild( $script );
1671 }
1672 }
1673 }
1674
1675 // 2. Remove all attributes that start with "on" (like onclick, onmouseover, etc.).
1676 $elements_with_on_attrs = $xpath->query( '//*[@*[starts-with(name(), "on")]]' );
1677 if ( $elements_with_on_attrs instanceof \DOMNodeList ) {
1678 foreach ( $elements_with_on_attrs as $element ) {
1679 if ( $element instanceof \DOMElement && $element->hasAttributes() ) {
1680 foreach ( iterator_to_array( $element->attributes ) as $attr ) {
1681 if ( $attr instanceof \DOMAttr && stripos( $attr->name, 'on' ) === 0 ) {
1682 $element->removeAttribute( $attr->name );
1683 }
1684 }
1685 }
1686 }
1687 }
1688
1689 // 3. Optionally remove target and target-related rel values (noopener, noreferrer) from links.
1690 if ( $remove_link_target ) {
1691 $links = $xpath->query( '//a[@target]' );
1692 if ( $links instanceof \DOMNodeList ) {
1693 foreach ( $links as $link ) {
1694 if ( $link instanceof \DOMElement ) {
1695 $link->removeAttribute( 'target' );
1696 $rel = $link->getAttribute( 'rel' );
1697 if ( $rel ) {
1698 $cleaned_rel = trim( (string) preg_replace( '/\s+/', ' ', (string) preg_replace( '/\b(noopener|noreferrer)\b/i', '', $rel ) ) );
1699 if ( $cleaned_rel ) {
1700 $link->setAttribute( 'rel', $cleaned_rel );
1701 } else {
1702 $link->removeAttribute( 'rel' );
1703 }
1704 }
1705 }
1706 }
1707 }
1708 }
1709
1710 // Return cleaned HTML.
1711 $body = $dom->getElementsByTagName( 'body' )->item( 0 );
1712 if ( $body instanceof \DOMNode ) {
1713 $cleaned_html = $dom->saveHTML( $body );
1714 return is_string( $cleaned_html ) ? $cleaned_html : '';
1715 }
1716 return '';
1717 }
1718
1719 /**
1720 * Encodes the given string with base64.
1721 * Moved from admin class to here.
1722 *
1723 * @param string $logo contains svg's.
1724 * @return string
1725 */
1726 public static function encode_svg( $logo ) {
1727 return 'data:image/svg+xml;base64,' . base64_encode( $logo ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1728 }
1729
1730 /**
1731 * Get plugin status
1732 *
1733 * @since 0.0.1
1734 * @since 1.7.0 moved to inc/helper.php from inc/admin-ajax.php
1735 *
1736 * @param string $plugin_init_file Plugin init file.
1737 * @return string
1738 */
1739 public static function get_plugin_status( $plugin_init_file ) {
1740
1741 $installed_plugins = get_plugins();
1742
1743 if ( ! isset( $installed_plugins[ $plugin_init_file ] ) ) {
1744 return 'Install';
1745 }
1746 if ( is_plugin_active( $plugin_init_file ) ) {
1747 return 'Activated';
1748 }
1749 return 'Installed';
1750 }
1751
1752 /**
1753 * Return the first installed plugin from a list, or a default if none exist.
1754 *
1755 * @since 2.0.0
1756 *
1757 * @param array<string> $plugins_to_check Plugin file paths to check, in priority order.
1758 * @param string $default Optional fallback plugin file path. Default empty string.
1759 *
1760 * @return string First installed plugin file path, or the default.
1761 */
1762 public static function get_plugin_if_installed( $plugins_to_check, $default = '' ) {
1763 if ( ! function_exists( 'get_plugins' ) ) {
1764 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1765 }
1766
1767 $plugins = get_plugins();
1768
1769 foreach ( self::get_array_value( $plugins_to_check ) as $plugin_file ) {
1770 if ( isset( $plugins[ $plugin_file ] ) ) {
1771 return $plugin_file;
1772 }
1773 }
1774
1775 return $default;
1776 }
1777
1778 /**
1779 * Check which Starter Templates plugin is installed and return its main plugin file path.
1780 *
1781 * @since 1.7.3
1782 *
1783 * @return string The main plugin file path of the installed Starter Templates plugin.
1784 */
1785 public static function check_starter_template_plugin() {
1786 return self::get_plugin_if_installed(
1787 [ 'astra-pro-sites/astra-pro-sites.php' ],
1788 'astra-sites/astra-sites.php'
1789 );
1790 }
1791
1792 /**
1793 * Get sureforms recommended integrations.
1794 *
1795 * @since 0.0.1
1796 * @since 1.7.0 moved to inc/helper.php from inc/admin-ajax.php
1797 *
1798 * @return array<mixed>
1799 */
1800 public static function sureforms_get_integration() {
1801 $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' );
1802 $logo_sure_triggers = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers.svg' );
1803 $logo_full = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers_full.svg' );
1804 $logo_sure_mails = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suremails.svg' );
1805 $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' );
1806 $logo_starter_templates = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/starterTemplates.svg' );
1807 $logo_sure_rank = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/surerank.svg' );
1808 $logo_sure_contact = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/surecontact.svg' );
1809 $logo_sure_donation = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suredonation.svg' );
1810
1811 $integrations = [
1812 'sure_donation' => [
1813 'title' => __( 'SureDonation', 'sureforms' ),
1814 'singleLineDescription' => __( 'Start Collecting Donations Today', 'sureforms' ),
1815 'subtitle' => __( 'Want to accept donations too? SureDonation makes it easy to collect contributions right on your WordPress site.', 'sureforms' ),
1816 'status' => self::get_plugin_status( 'suredonation/suredonation.php' ),
1817 'slug' => 'suredonation',
1818 'path' => 'suredonation/suredonation.php',
1819 'logo' => self::encode_svg( is_string( $logo_sure_donation ) ? $logo_sure_donation : '' ),
1820 ],
1821 'sure_contact' => [
1822 'title' => __( 'SureContact', 'sureforms' ),
1823 'singleLineDescription' => __( 'Turn Emails Into Revenue with a CRM Built for Your Website!', 'sureforms' ),
1824 'subtitle' => __( 'Send newsletters, run campaigns, set up automations, manage contacts, and see exactly how much revenue your emails generate, all in one place.', 'sureforms' ),
1825 'status' => self::get_plugin_status( 'surecontact/surecontact.php' ),
1826 'slug' => 'surecontact',
1827 'path' => 'surecontact/surecontact.php',
1828 'logo' => self::encode_svg( is_string( $logo_sure_contact ) ? $logo_sure_contact : '' ),
1829 ],
1830 'sure_mails' => [
1831 'title' => __( 'SureMail', 'sureforms' ),
1832 'singleLineDescription' => __( 'Boost Your Email Deliverability Instantly!', 'sureforms' ),
1833 'subtitle' => __( 'Access a powerful, easy-to-use email delivery service that ensures your emails land in inboxes, not spam folders. Automate your WordPress email workflows confidently with SureMail.', 'sureforms' ),
1834 'status' => self::get_plugin_status( 'suremails/suremails.php' ),
1835 'slug' => 'suremails',
1836 'path' => 'suremails/suremails.php',
1837 'logo' => self::encode_svg( is_string( $logo_sure_mails ) ? $logo_sure_mails : '' ),
1838 ],
1839 'sure_triggers' => [
1840 'title' => __( 'OttoKit', 'sureforms' ),
1841 'singleLineDescription' => __( 'Automate your WordPress workflows effortlessly.', 'sureforms' ),
1842 'subtitle' => __( 'Connect your WordPress plugins and favourite apps, automate tasks, and sync data effortlessly using OttoKit’s clean, visual workflow builder — no coding or complex setup required.', 'sureforms' ),
1843 'status' => self::get_plugin_status( 'suretriggers/suretriggers.php' ),
1844 'slug' => 'suretriggers',
1845 'path' => 'suretriggers/suretriggers.php',
1846 'logo' => self::encode_svg( is_string( $logo_sure_triggers ) ? $logo_sure_triggers : '' ),
1847 'logo_full' => self::encode_svg( is_string( $logo_full ) ? $logo_full : '' ),
1848 'connected' => $suretrigger_connected,
1849 'connection_url' => admin_url( 'admin.php?page=suretriggers' ),
1850 ],
1851 'starter_templates' => [
1852 'title' => __( 'Starter Templates', 'sureforms' ),
1853 'singleLineDescription' => __( 'Launch Beautiful Websites in Minutes!', 'sureforms' ),
1854 'subtitle' => __( 'Choose from professionally designed templates, import with one click, and customize effortlessly to match your brand.', 'sureforms' ),
1855 'status' => self::get_plugin_status( self::check_starter_template_plugin() ),
1856 'slug' => 'astra-sites',
1857 'path' => self::check_starter_template_plugin(),
1858 'logo' => self::encode_svg( is_string( $logo_starter_templates ) ? $logo_starter_templates : '' ),
1859 ],
1860 ];
1861
1862 $elementor_installed = self::get_plugin_if_installed( [ 'elementor/elementor.php' ] );
1863
1864 if ( $elementor_installed ) {
1865 $integrations['uae'] = [
1866 'title' => __( 'Ultimate Addons for Elementor', 'sureforms' ),
1867 'singleLineDescription' => __( 'Power Up Elementor to Build Stunning Websites Faster!', 'sureforms' ),
1868 'subtitle' => __( 'Enhance Elementor with powerful widgets and templates. Build stunning, high-performing websites faster with creative design elements and seamless customization.', 'sureforms' ),
1869 'status' => self::get_plugin_status( 'header-footer-elementor/header-footer-elementor.php' ),
1870 'slug' => 'header-footer-elementor',
1871 'path' => 'header-footer-elementor/header-footer-elementor.php',
1872 'logo' => self::encode_svg( is_string( $logo_uae ) ? $logo_uae : '' ),
1873 ];
1874 } else {
1875 $integrations['sure_rank'] = [
1876 'title' => __( 'SureRank', 'sureforms' ),
1877 'singleLineDescription' => __( 'Elevate Your SEO and Climb Search Rankings Effortlessly!', 'sureforms' ),
1878 'subtitle' => __( 'Boost your website\'s visibility with smart SEO automation. Optimize content, track keyword performance, and get actionable insights, all inside WordPress.', 'sureforms' ),
1879 'status' => self::get_plugin_status( 'surerank/surerank.php' ),
1880 'slug' => 'surerank',
1881 'path' => 'surerank/surerank.php',
1882 'logo' => self::encode_svg( is_string( $logo_sure_rank ) ? $logo_sure_rank : '' ),
1883 ];
1884 }
1885
1886 return apply_filters( 'srfm_integrated_plugins', $integrations );
1887 }
1888
1889 /**
1890 * Get the current rotating plugin for the banner.
1891 *
1892 * Plugins rotate every 2 days. Only non-activated plugins are shown.
1893 * Returns false if all plugins are activated.
1894 *
1895 * @since 2.0.0
1896 * @return array<string, mixed>|false The current plugin data or false if all plugins are activated.
1897 */
1898 public static function get_rotating_plugin_banner() {
1899 $all_plugins = self::sureforms_get_integration();
1900
1901 if ( ! is_array( $all_plugins ) ) {
1902 return false;
1903 }
1904
1905 $available_plugins = [];
1906
1907 // Only include non-activated plugins.
1908 foreach ( $all_plugins as $plugin ) {
1909 if ( ! is_array( $plugin ) ) {
1910 continue;
1911 }
1912 if ( isset( $plugin['status'] ) && is_string( $plugin['status'] ) && 'Activated' !== $plugin['status'] ) {
1913 $available_plugins[] = $plugin;
1914 }
1915 }
1916
1917 // Re-index the array to have sequential numeric keys.
1918 $available_plugins = array_values( $available_plugins );
1919 $total_plugins = count( $available_plugins );
1920
1921 // Hide section if all plugins are active.
1922 if ( 0 === $total_plugins ) {
1923 return false;
1924 }
1925
1926 // Get stored rotation data.
1927 $rotation_data = self::get_srfm_option( 'plugin_banner_rotation', [] );
1928
1929 if ( ! is_array( $rotation_data ) ) {
1930 $rotation_data = [];
1931 }
1932
1933 // Initialize rotation data if empty.
1934 if ( empty( $rotation_data ) ) {
1935 $current_time = time();
1936 self::update_srfm_option(
1937 'plugin_banner_rotation',
1938 [
1939 'last_rotation_date' => $current_time,
1940 'plugin_index' => 0,
1941 ]
1942 );
1943 return isset( $available_plugins[0] ) && is_array( $available_plugins[0] ) ? $available_plugins[0] : false;
1944 }
1945
1946 $last_rotation_date = isset( $rotation_data['last_rotation_date'] ) && is_int( $rotation_data['last_rotation_date'] ) ? $rotation_data['last_rotation_date'] : 0;
1947 $plugin_index = isset( $rotation_data['plugin_index'] ) && is_numeric( $rotation_data['plugin_index'] ) ? intval( $rotation_data['plugin_index'] ) : 0;
1948
1949 $current_time = time();
1950 $days_since_rotation = ( $current_time - $last_rotation_date ) / DAY_IN_SECONDS;
1951
1952 // Rotate every 2 days.
1953 if ( $days_since_rotation >= 2 ) {
1954 // Rotate to next plugin.
1955 ++$plugin_index;
1956 $plugin_index %= $total_plugins;
1957
1958 // Update the rotation data.
1959 self::update_srfm_option(
1960 'plugin_banner_rotation',
1961 [
1962 'last_rotation_date' => $current_time,
1963 'plugin_index' => $plugin_index,
1964 ]
1965 );
1966 }
1967
1968 // Ensure the index is within bounds.
1969 if ( $plugin_index >= $total_plugins ) {
1970 $plugin_index = 0;
1971 }
1972
1973 return isset( $available_plugins[ $plugin_index ] ) && is_array( $available_plugins[ $plugin_index ] ) ? $available_plugins[ $plugin_index ] : false;
1974 }
1975
1976 /**
1977 * Get a value from the srfm_options array.
1978 *
1979 * @param string $key The key to retrieve.
1980 * @param mixed $default The default value to return if the key does not exist.
1981 * @since 1.8.0
1982 * @return mixed
1983 */
1984 public static function get_srfm_option( $key, $default = null ) {
1985 $options = get_option( 'srfm_options', [] );
1986 if ( ! is_array( $options ) ) {
1987 $options = [];
1988 }
1989 return array_key_exists( $key, $options ) ? $options[ $key ] : $default;
1990 }
1991
1992 /**
1993 * Update a value in the srfm_options array.
1994 *
1995 * @param string $key The key to update.
1996 * @param mixed $value The value to set.
1997 * @since 1.8.0
1998 * @return void
1999 */
2000 public static function update_srfm_option( $key, $value ) {
2001 $options = get_option( 'srfm_options', [] );
2002 if ( ! is_array( $options ) ) {
2003 $options = [];
2004 }
2005 $options[ $key ] = $value;
2006 update_option( 'srfm_options', $options );
2007 }
2008
2009 /**
2010 * Get the WordPress file types.
2011 *
2012 * @since 1.7.4
2013 * @return array<string,mixed> An associative array representing the file types.
2014 */
2015 public static function get_wp_file_types() {
2016 $formats = [];
2017 $mimes = get_allowed_mime_types();
2018 $maxsize = wp_max_upload_size() / 1048576;
2019 if ( ! empty( $mimes ) ) {
2020 foreach ( $mimes as $type => $mime ) {
2021 $multiple = explode( '|', $type );
2022 foreach ( $multiple as $single ) {
2023 $formats[] = $single;
2024 }
2025 }
2026 }
2027
2028 return [
2029 'formats' => $formats,
2030 'maxsize' => $maxsize,
2031 ];
2032 }
2033
2034 /**
2035 * Determines if the SureForms Pro plugin is installed and active.
2036 *
2037 * Checks for the presence of the SRFM_PRO_VER constant.
2038 *
2039 * @since 1.8.0
2040 *
2041 * @return bool True if the Pro plugin is active; false otherwise.
2042 */
2043 public static function has_pro() {
2044 return defined( 'SRFM_PRO_VER' );
2045 }
2046
2047 /**
2048 * Verifies the request by checking the nonce and user capabilities.
2049 *
2050 * @param string $request_type The type of request, either 'rest' or 'ajax'.
2051 * @param string $nonce_action The action name for the nonce.
2052 * @param string $nonce_name The name of the nonce field.
2053 * @param string $capability The capability required to perform the action. Default is 'manage_options'.
2054 *
2055 * @since 1.10.0
2056 * @return void
2057 */
2058 public static function verify_nonce_and_capabilities( $request_type, $nonce_action, $nonce_name, $capability = 'manage_options' ) {
2059
2060 if ( ! is_string( $nonce_action ) || ! is_string( $nonce_name ) || empty( $nonce_action ) || empty( $nonce_name ) ) {
2061 wp_send_json_error(
2062 [ 'message' => __( 'Invalid nonce action or name.', 'sureforms' ) ],
2063 400
2064 );
2065 }
2066
2067 // Verify nonce for security.
2068 if ( 'rest' === $request_type ) {
2069 // For REST API requests, use the WP_REST_Request object to verify the nonce.
2070 if ( ! wp_verify_nonce( $nonce_action, $nonce_name ) ) {
2071 wp_send_json_error(
2072 [ 'message' => __( 'Invalid security token.', 'sureforms' ) ],
2073 403
2074 );
2075 }
2076 } elseif ( 'ajax' === $request_type ) {
2077 // For non-REST requests, use the standard nonce verification.
2078 if ( ! check_ajax_referer( $nonce_action, $nonce_name, false ) ) {
2079 wp_send_json_error(
2080 [ 'message' => __( 'Invalid security token.', 'sureforms' ) ],
2081 403
2082 );
2083 }
2084 } else {
2085 // If the request type is not recognized, return an error.
2086 wp_send_json_error(
2087 [ 'message' => __( 'Invalid request type.', 'sureforms' ) ],
2088 400
2089 );
2090 }
2091
2092 // Check user capabilities.
2093 if ( ! current_user_can( $capability ) ) {
2094 wp_send_json_error(
2095 [ 'message' => esc_html__( 'You do not have permission to perform this action.', 'sureforms' ) ],
2096 403
2097 );
2098 }
2099 }
2100
2101 /**
2102 * Get the block name from a field name by extracting the first two parts.
2103 *
2104 * @param string $field_name The full field name (e.g., 'srfm-text-lbl-123').
2105 *
2106 * @since 1.11.0
2107 * @return string The block name (e.g., 'srfm-text').
2108 */
2109 public static function get_block_name_from_field( $field_name ) {
2110 return implode( '-', array_slice( explode( '-', explode( '-lbl-', $field_name )[0] ), 0, 2 ) );
2111 }
2112
2113 /**
2114 * Check if any of the top 10 popular WordPress SMTP plugins is active using array_intersect.
2115 *
2116 * @since 1.9.1
2117 * @return bool True if any SMTP plugin is active, false otherwise.
2118 */
2119 public static function is_any_smtp_plugin_active() {
2120 $smtp_plugins = [
2121 'wp-mail-smtp/wp_mail_smtp.php',
2122 'post-smtp/postman-smtp.php',
2123 'easy-wp-smtp/easy-wp-smtp.php',
2124 'wp-smtp/wp-smtp.php',
2125 'newsletter/plugin.php',
2126 'fluent-smtp/fluent-smtp.php',
2127 'pepipost-smtp/pepipost-smtp.php',
2128 'mail-bank/wp-mail-bank.php',
2129 'smtp-mailer/smtp-mailer.php',
2130 'suremails/suremails.php',
2131 'site-mailer/site-mailer.php',
2132 ];
2133
2134 $active_plugins = (array) get_option( 'active_plugins', [] );
2135 // For multisite, merge sitewide active plugins.
2136 if ( is_multisite() ) {
2137 $network_plugins = (array) get_site_option( 'active_sitewide_plugins', [] );
2138 $active_plugins = array_merge( $active_plugins, array_keys( $network_plugins ) );
2139 }
2140
2141 return (bool) array_intersect( $smtp_plugins, $active_plugins );
2142 }
2143
2144 /**
2145 * Apply a filter and return the filtered value only if it's a non-empty array.
2146 * Otherwise, return the default array.
2147 *
2148 * @param string $filter_name The name of the filter to apply.
2149 * @param mixed $default The default array to return if the filtered result is invalid.
2150 * @param mixed ...$args Additional arguments to pass to the filter.
2151 *
2152 * @return array The filtered array if valid, otherwise the default.
2153 */
2154 public static function apply_filters_as_array( $filter_name, $default, ...$args ) {
2155 // Ensure $default is an array.
2156 if ( ! is_array( $default ) ) {
2157 $default = [];
2158 }
2159
2160 // Validate the filter name.
2161 if ( ! is_string( $filter_name ) || empty( $filter_name ) ) {
2162 return $default;
2163 }
2164
2165 // Apply the filter with additional arguments.
2166 $filtered = apply_filters( $filter_name, $default, ...$args );
2167
2168 // Return filtered result if it's a non-empty array.
2169 return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default;
2170 }
2171
2172 /**
2173 * Get forms with entry counts for a specific time period.
2174 *
2175 * @param int $timestamp The timestamp to get entries after.
2176 * @param int $limit Maximum number of forms to return (0 for all).
2177 * @param bool $sort Whether to sort by entry count descending.
2178 * @return array Array of form data with entry counts.
2179 * @since 1.9.1
2180 */
2181 public static function get_forms_with_entry_counts( $timestamp, $limit = 0, $sort = true ) {
2182 // Get all published forms with post objects for bulk title access.
2183 $args = [
2184 'post_type' => SRFM_FORMS_POST_TYPE,
2185 'posts_per_page' => -1,
2186 'post_status' => 'publish',
2187 'orderby' => 'ID',
2188 'order' => 'DESC',
2189 'no_found_rows' => true,
2190 'update_post_term_cache' => false,
2191 'update_post_meta_cache' => false,
2192 ];
2193
2194 $query = new \WP_Query( $args );
2195
2196 if ( ! $query->have_posts() ) {
2197 return [];
2198 }
2199
2200 $all_forms = [];
2201
2202 // Process posts directly from the query results without touching global $post.
2203 foreach ( $query->posts as $form ) {
2204 // Ensure we have a valid post object.
2205 if ( ! $form instanceof \WP_Post ) {
2206 continue;
2207 }
2208
2209 $form_id = (int) $form->ID;
2210 if ( $form_id <= 0 ) {
2211 continue;
2212 }
2213
2214 // Get entries count after the timestamp for this specific form.
2215 $entry_count = Entries::get_entries_count_after( $timestamp, $form_id );
2216
2217 // Get form title directly from post object, use "Blank Form" if empty.
2218 $form_title = $form->post_title;
2219 if ( empty( trim( self::get_string_value( $form_title ) ) ) ) {
2220 $form_title = __( 'Blank Form', 'sureforms' );
2221 }
2222
2223 $all_forms[] = [
2224 'form_id' => $form_id,
2225 'title' => $form_title,
2226 'count' => $entry_count,
2227 ];
2228 }
2229
2230 // Sort by count descending, then by form_id descending for consistency.
2231 if ( $sort ) {
2232 usort(
2233 $all_forms,
2234 static function( $a, $b ) {
2235 if ( $a['count'] === $b['count'] ) {
2236 return $b['form_id'] - $a['form_id'];
2237 }
2238 return $b['count'] - $a['count'];
2239 }
2240 );
2241 }
2242
2243 // Return limited results if specified.
2244 if ( $limit > 0 ) {
2245 return array_slice( $all_forms, 0, $limit );
2246 }
2247
2248 return $all_forms;
2249 }
2250
2251 /**
2252 * Check if the given form ID is valid SureForms form ID.
2253 * A valid form ID is a numeric value that corresponds to an existing SureForms form in the database.
2254 *
2255 * @since 1.9.1
2256 *
2257 * @param int|string|mixed $form_id The form ID to validate.
2258 * @return bool True if the form ID is valid, false otherwise.
2259 */
2260 public static function is_valid_form( $form_id ) {
2261
2262 // Check for a valid form ID.
2263 if ( empty( $form_id ) || ! is_numeric( $form_id ) ) {
2264 return false;
2265 }
2266
2267 // Check if the form ID exists in the database.
2268 $form = get_post( self::get_integer_value( $form_id ) );
2269
2270 // If the form does not exist or is not of the correct post type, return false.
2271 if ( ! $form || ! is_a( $form, 'WP_Post' ) || SRFM_FORMS_POST_TYPE !== $form->post_type ) {
2272 return false;
2273 }
2274
2275 return true;
2276 }
2277
2278 /**
2279 * Get the timestamp from a string.
2280 *
2281 * This function uses WordPress's configured timezone (from Settings → General → Timezone)
2282 * to ensure consistent behavior regardless of the server's timezone settings.
2283 *
2284 * @param string $date The date in YYYY-MM-DD format (e.g., '2026-01-10').
2285 * @param string $hours The hours in 12-hour format (e.g., '12', '01'-'12').
2286 * @param string $minutes The minutes (e.g., '00', '00'-'59').
2287 * @param string $meridiem The meridiem (e.g., 'AM' or 'PM').
2288 *
2289 * @since 1.10.1
2290 * @return int|false The timestamp if successful, false otherwise.
2291 */
2292 public static function get_timestamp_from_string( $date, $hours = '12', $minutes = '00', $meridiem = 'AM' ) {
2293
2294 if ( empty( $date ) || ! is_string( $date ) ) {
2295 return false; // Invalid input.
2296 }
2297
2298 // Ensure the date is in a valid format of YYYY-MM-DD.
2299 if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
2300 return false; // Invalid date format.
2301 }
2302
2303 $time_string = $date . ' ' . $hours . ':' . $minutes . ' ' . $meridiem;
2304
2305 // Convert to timestamp using WordPress timezone.
2306 // This ensures the date/time is interpreted in the site's configured timezone,
2307 // not the server's timezone or PHP's default timezone.
2308 try {
2309 $datetime = date_create( $time_string, wp_timezone() );
2310
2311 if ( false === $datetime ) {
2312 return false;
2313 }
2314
2315 $timestamp = $datetime->getTimestamp();
2316
2317 if ( is_int( $timestamp ) && $timestamp > 0 ) {
2318 return $timestamp;
2319 }
2320 } catch ( \Exception $e ) {
2321 // If timezone conversion fails, return false.
2322 return false;
2323 }
2324
2325 // If conversion fails, return false.
2326 return false;
2327 }
2328
2329 /**
2330 * Generate a unique ID for the saved form.
2331 * Also ensures that the generated ID does not already exist in the database table.
2332 *
2333 * @param class-string $class The class name where the get method is defined to check for existing IDs.
2334 * @param int<1, max> $length The length of the random bytes to generate. Default is 8.
2335 * @return string
2336 * @since 2.2.0
2337 */
2338 public static function generate_unique_id( $class, $length = 8 ) {
2339 // Ensure length is at least 1.
2340 $length = max( 1, $length );
2341
2342 do {
2343 $id = bin2hex( random_bytes( $length ) );
2344 } while ( is_callable( [ $class, 'get' ] ) && call_user_func( [ $class, 'get' ], $id ) );
2345 return $id;
2346 }
2347
2348 /**
2349 * Log error messages to the error log.
2350 *
2351 * This function checks if error_log function exists, validates the message,
2352 * and logs it with the print_r second argument set to true.
2353 *
2354 * Logging is disabled by default. To enable logging, add this to wp-config.php:
2355 * define( 'SRFM_LOG', true );
2356 *
2357 * @param mixed $message The error message to log. Can be string or any type.
2358 * @param string $prefix Optional prefix to add before the message. Default: 'Log :'.
2359 *
2360 * @since 2.0.0
2361 * @return void
2362 */
2363 public static function srfm_log( $message, $prefix = 'Log :' ) {
2364 // Check if logging is enabled via SRFM_LOG constant.
2365 if ( ! defined( 'SRFM_LOG' ) ) {
2366 return;
2367 }
2368 unset( $message, $prefix );
2369 }
2370
2371 /**
2372 * Encodes data to base64 after JSON encoding with validation.
2373 *
2374 * This function checks if the data is non-empty and valid for JSON encoding.
2375 * If data is not valid, returns an empty string.
2376 * Otherwise, it attempts to JSON encode and then base64 encode the result.
2377 *
2378 * @param mixed $data The data to JSON encode and then base64 encode.
2379 * @return string The base64-encoded JSON string, or empty string on failure.
2380 */
2381 public static function srfm_base64_json_encode( $data ) {
2382 if ( empty( $data ) || ! is_array( $data ) ) {
2383 return '';
2384 }
2385
2386 $json = wp_json_encode( $data );
2387 if ( false === $json || '' === $json ) {
2388 return '';
2389 }
2390
2391 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
2392 return base64_encode( $json );
2393 }
2394
2395 /**
2396 * Get the visitor's IP address.
2397 *
2398 * Centralised IP detection that checks common proxy headers before
2399 * falling back to REMOTE_ADDR. Handles comma-separated IPs that
2400 * load-balancers / CDNs may append (takes the first, i.e. client IP).
2401 *
2402 * NOTE: Existing callers (Smart_Tags::get_the_user_ip, Front_End::get_user_ip,
2403 * inline reads in Form_Submit) can be migrated to this method in the future
2404 * to avoid duplicating the same header-chain logic.
2405 *
2406 * @since 2.8.0
2407 * @return string Validated IP address, or empty string if unavailable.
2408 */
2409 public static function get_visitor_ip() {
2410 $headers = [
2411 'HTTP_CLIENT_IP',
2412 'HTTP_X_FORWARDED_FOR',
2413 'HTTP_X_REAL_IP',
2414 'HTTP_X_FORWARDED',
2415 'HTTP_FORWARDED_FOR',
2416 'HTTP_FORWARDED',
2417 'REMOTE_ADDR',
2418 ];
2419
2420 foreach ( $headers as $header ) {
2421 if ( empty( $_SERVER[ $header ] ) ) {
2422 continue;
2423 }
2424
2425 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by FILTER_VALIDATE_IP below.
2426 $raw = wp_unslash( $_SERVER[ $header ] );
2427
2428 // Proxies may send comma-separated IPs; the first is the original client.
2429 if ( false !== strpos( $raw, ',' ) ) {
2430 $raw = trim( explode( ',', $raw )[0] );
2431 }
2432
2433 $ip = filter_var( $raw, FILTER_VALIDATE_IP );
2434 if ( false !== $ip ) {
2435 /**
2436 * Filters the detected visitor IP address.
2437 *
2438 * @since 2.8.0
2439 *
2440 * @param string $ip Validated IP address.
2441 */
2442 return apply_filters( 'srfm_visitor_ip', $ip );
2443 }
2444 }
2445
2446 return '';
2447 }
2448
2449 /**
2450 * Detect the visitor's 2-letter country code via server-side IP geolocation.
2451 *
2452 * Prefers a CDN/server-provided country header (Cloudflare, CloudFront, mod_geoip)
2453 * when present — free, instant and cache-safe. Otherwise calls ipapi.co once per
2454 * visitor IP and caches the result in a transient for 24 hours so subsequent
2455 * lookups for the same IP resolve instantly. Failures are cached for a short TTL
2456 * (see get_geo_failure_ttl()) to avoid retry storms while still self-healing, and
2457 * a site-wide hourly cap (filterable via `srfm_geo_api_hourly_cap`, default 40)
2458 * bounds outbound calls. Private/reserved IPs are rejected up front.
2459 *
2460 * Intended to be called per-visitor (e.g. via the geo-country REST route) so
2461 * the result is correct on full-page-cached sites instead of being baked into
2462 * the cached HTML.
2463 *
2464 * Local testing: private/loopback IPs (e.g. 127.0.0.1) cannot be geolocated, so
2465 * inject a public IP via the `srfm_visitor_ip` filter to exercise detection:
2466 *
2467 * add_filter( 'srfm_visitor_ip', static fn() => '8.8.8.8' ); // US; try 1.1.1.1 etc.
2468 *
2469 * @param string $fallback Country code returned when detection is unavailable.
2470 * @since 2.11.1
2471 * @return string Lowercase 2-letter country code.
2472 */
2473 public static function get_geo_country( $fallback = 'us' ) {
2474 // Prefer a CDN/server-provided country header — free, instant, per-visitor
2475 // and cache-safe. It is independent of the connecting IP (it still resolves
2476 // when the visitor IP is private/loopback, e.g. local dev or behind a
2477 // proxy), so it must be checked before the IP-based path below.
2478 $cdn_country = self::get_cdn_country();
2479 if ( '' !== $cdn_country ) {
2480 return $cdn_country;
2481 }
2482
2483 $ip = self::get_visitor_ip();
2484 if ( empty( $ip ) ) {
2485 return $fallback;
2486 }
2487
2488 // Reject private/reserved IPs: ipapi.co cannot geolocate them, and accepting
2489 // them would let spoofed X-Forwarded-For headers flood the transient cache.
2490 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
2491 return $fallback;
2492 }
2493
2494 // Versioned key (v2) so stale failure transients written by older code
2495 // (which used the byte-identical 'srfm_geo_' key and a 1h TTL) don't shadow
2496 // the current CDN/fallback logic for the failure-TTL window after an upgrade.
2497 $cache_key = 'srfm_geo_v2_' . md5( $ip );
2498 $cached = get_transient( $cache_key );
2499 if ( is_string( $cached ) && '' !== $cached ) {
2500 return $cached;
2501 }
2502
2503 // Site-wide hourly cap on outbound ipapi calls; the counter rolls over
2504 // every hour (key includes YmdH) so it never needs an explicit reset.
2505 $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' );
2506 $quota_cap = self::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) );
2507 $count = self::get_integer_value( get_transient( $quota_key ) );
2508 if ( $count >= $quota_cap ) {
2509 self::srfm_log( $quota_cap, 'SRFM geo lookup skipped (hourly cap reached):' );
2510 // Do NOT cache a per-IP transient here: the hourly counter already
2511 // blocks outbound calls, and writing per IP is the one path not bounded
2512 // by the cap — it would let spoofed X-Forwarded-For headers churn
2513 // wp_options / the object cache under sustained traffic.
2514 return $fallback;
2515 }
2516 set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS );
2517
2518 // Pass the visitor's IP explicitly via /{ip}/json/ — the request originates
2519 // from the server, so the bare /json/ endpoint would return the host's country.
2520 $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/';
2521
2522 // ipapi.co's free (keyless) tier is heavily rate-limited, so unauthenticated
2523 // lookups are best-effort and often fail to the configured fallback. Sites
2524 // that need reliable IP-based detection can supply a paid ipapi.co key via
2525 // this filter; CDN-fronted sites resolve earlier via get_cdn_country() and
2526 // never reach this call.
2527 $api_key = self::get_string_value( apply_filters( 'srfm_ipapi_api_key', '' ) );
2528 if ( '' !== $api_key ) {
2529 $url = add_query_arg( 'key', rawurlencode( $api_key ), $url );
2530 }
2531
2532 $response = wp_remote_get(
2533 $url,
2534 [
2535 'timeout' => 3,
2536 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)',
2537 ]
2538 );
2539
2540 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
2541 $detail = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response );
2542 self::srfm_log( $detail, 'SRFM geo lookup failed (transport):' );
2543 set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2544 return $fallback;
2545 }
2546
2547 $body = json_decode( wp_remote_retrieve_body( $response ), true );
2548
2549 // ipapi.co's free tier returns HTTP 200 with a JSON error body
2550 // (e.g. {"error":true,"reason":"RateLimited"}) when throttled — treat as a failure.
2551 if ( is_array( $body ) && ! empty( $body['error'] ) ) {
2552 $reason = ! empty( $body['reason'] ) && is_string( $body['reason'] ) ? $body['reason'] : 'unknown';
2553 self::srfm_log( $reason, 'SRFM geo lookup failed (ipapi error):' );
2554 set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2555 return $fallback;
2556 }
2557
2558 if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) {
2559 self::srfm_log( wp_remote_retrieve_response_code( $response ), 'SRFM geo lookup failed (no country_code):' );
2560 set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2561 return $fallback;
2562 }
2563
2564 $country = strtolower( $body['country_code'] );
2565
2566 // Validate the external API response is a valid 2-letter country code.
2567 if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) {
2568 self::srfm_log( $country, 'SRFM geo lookup failed (invalid country code):' );
2569 set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2570 return $fallback;
2571 }
2572
2573 set_transient( $cache_key, $country, DAY_IN_SECONDS );
2574
2575 return $country;
2576 }
2577
2578 /**
2579 * Read a visitor country code from a CDN / server geo header, if present.
2580 *
2581 * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that
2582 * injects the visitor's country as a request header. This is free, instant,
2583 * per-visitor and works on full-page-cached sites, so we prefer it over an
2584 * outbound API call. Returns '' when no usable header is present.
2585 *
2586 * NOTE: these headers are client-spoofable when the site is NOT actually behind
2587 * the named CDN/proxy. The value is used only as a phone-field UI default (the
2588 * pre-selected flag), never for access control, so spoofing has no security
2589 * impact here — at worst a visitor sees a different default country.
2590 *
2591 * @since 2.11.1
2592 * @return string Lowercase 2-letter country code, or '' when unavailable.
2593 */
2594 private static function get_cdn_country() {
2595 $headers = [
2596 'HTTP_CF_IPCOUNTRY', // Cloudflare.
2597 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront.
2598 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind.
2599 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies.
2600 'HTTP_X_COUNTRY_CODE', // Some CDNs.
2601 ];
2602
2603 foreach ( $headers as $header ) {
2604 if ( empty( $_SERVER[ $header ] ) ) {
2605 continue;
2606 }
2607
2608 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below.
2609 $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) );
2610
2611 // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values.
2612 if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) {
2613 return $code;
2614 }
2615 }
2616
2617 /**
2618 * Filters the CDN-derived visitor country code, before the ipapi.co fallback.
2619 *
2620 * Lets sites short-circuit detection with a server-side country code (e.g.
2621 * from a custom header) without any outbound API call.
2622 *
2623 * @since 2.11.1
2624 *
2625 * @param string $code Lowercase 2-letter country code, or '' if none found.
2626 */
2627 return apply_filters( 'srfm_cdn_country', '' );
2628 }
2629
2630 /**
2631 * TTL (in seconds) for caching a failed geo lookup.
2632 *
2633 * Short by default so a transient blip (rate-limit, timeout) self-heals on the
2634 * next visit instead of pinning the fallback country for a full hour, while
2635 * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`.
2636 *
2637 * @since 2.11.1
2638 * @return int
2639 */
2640 private static function get_geo_failure_ttl() {
2641 return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) );
2642 }
2643
2644 }
2645