PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 All 97 releases
sureforms / inc / helper.php

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

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