PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/helper.php +2057 -119 0.0.13 → 2.12.8 View file →
@@ -7,13 +7,12 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 +use SRFM\Inc\Compatibility\Multilingual\String_Translator;
12 +use SRFM\Inc\Database\Tables\Entries;
11 13 use SRFM\Inc\Traits\Get_Instance;
12 14 use WP_Error;
13 -use WP_REST_Request;
14 -use WP_Post_Type;
15 -use WP_Query;
16 15 use WP_Post;
17 16
18 17 if ( ! defined( 'ABSPATH' ) ) {
19 18 exit; // Exit if accessed directly.
@@ -27,8 +26,35 @@
27 26 class Helper {
28 27 use Get_Instance;
29 28
30 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 + /**
31 57 * Sureforms SVGs.
32 58 *
33 59 * @var mixed srfm_svgs
34 60 */
@@ -40,14 +66,31 @@
40 66 * @since 0.0.2
41 67 * @return array<string>
42 68 */
43 69 public static function get_common_err_msg() {
70 + $translator = String_Translator::get_instance();
44 71 return [
45 - 'required' => __( 'This field is required.', 'sureforms' ),
46 - 'unique' => __( 'Value needs to be unique.', 'sureforms' ),
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' ) ),
47 74 ];
48 75 }
49 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 + }
50 93
51 94 /**
52 95 * Checks if current value is string or else returns default value
53 96 *
@@ -58,15 +101,16 @@
58 101 */
59 102 public static function get_string_value( $data ) {
60 103 if ( is_scalar( $data ) ) {
61 104 return (string) $data;
62 - } elseif ( is_object( $data ) && method_exists( $data, '__toString' ) ) {
105 + }
106 + if ( is_object( $data ) && method_exists( $data, '__toString' ) ) {
63 107 return $data->__toString();
64 - } elseif ( is_null( $data ) ) {
108 + }
109 + if ( is_null( $data ) ) {
65 110 return '';
66 - } else {
111 + }
67 112 return '';
68 - }
69 113 }
70 114 /**
71 115 * Checks if current value is number or else returns default value
72 116 *
@@ -78,32 +122,56 @@
78 122 */
79 123 public static function get_integer_value( $value, $base = 10 ) {
80 124 if ( is_numeric( $value ) ) {
81 125 return (int) $value;
82 - } elseif ( is_string( $value ) ) {
126 + }
127 + if ( is_string( $value ) ) {
83 128 $trimmed_value = trim( $value );
84 129 return intval( $trimmed_value, $base );
85 - } else {
130 + }
86 131 return 0;
87 - }
88 132 }
89 133
90 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 + /**
91 159 * Checks if current value is an array or else returns default value
92 160 *
93 161 * @param mixed $data Data which needs to be checked if it is an array.
94 162 *
95 163 * @since 0.0.3
96 - * @return array<mixed>
164 + * @return array
97 165 */
98 166 public static function get_array_value( $data ) {
99 167 if ( is_array( $data ) ) {
100 168 return $data;
101 - } elseif ( is_null( $data ) ) {
169 + }
170 + if ( is_null( $data ) ) {
102 171 return [];
103 - } else {
172 + }
104 173 return (array) $data;
105 - }
106 174 }
107 175
108 176 /**
109 177 * Extracts the field type from the dynamic field key ( or field slug ).
@@ -121,8 +189,60 @@
121 189 return trim( explode( '-', $field_key )[1] );
122 190 }
123 191
124 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 + /**
125 245 * Returns the proper sanitize callback functions according to the field type.
126 246 *
127 247 * @param string $field_type HTML field type.
128 248 * @since 0.0.6
@@ -133,16 +253,15 @@
133 253 'srfm_field_type_sanitize_functions',
134 254 [
135 255 'url' => 'esc_url_raw',
136 256 'input' => 'sanitize_text_field',
137 - 'number' => [ __CLASS__, 'sanitize_number' ],
257 + 'number' => [ self::class, 'sanitize_number' ],
138 258 'email' => 'sanitize_email',
139 - 'textarea' => 'sanitize_textarea_field',
259 + 'textarea' => [ self::class, 'sanitize_textarea' ],
140 260 ]
141 261 );
142 262
143 - return isset( $callbacks[ $field_type ] ) ? $callbacks[ $field_type ] : 'sanitize_text_field';
144 -
263 + return $callbacks[ $field_type ] ?? 'sanitize_text_field';
145 264 }
146 265
147 266 /**
148 267 * Sanitizes a numeric value.
@@ -152,9 +271,9 @@
152 271 * If the value is not numeric, it sanitizes it as a text field.
153 272 *
154 273 * @param mixed $value The value to be sanitized.
155 274 * @since 0.0.6
156 - * @return integer|float|string The sanitized value.
275 + * @return int|float|string The sanitized value.
157 276 */
158 277 public static function sanitize_number( $value ) {
159 278 if ( ! is_numeric( $value ) ) {
160 279 // phpcs:ignore /** @phpstan-ignore-next-line */
@@ -165,8 +284,27 @@
165 284 return sanitize_text_field( filter_var( $value, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION | FILTER_FLAG_ALLOW_THOUSAND ) );
166 285 }
167 286
168 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 + /**
169 307 * This function sanitizes the submitted form data according to the field type.
170 308 *
171 309 * @param array<mixed> $form_data $form_data User submitted form data.
172 310 * @since 0.0.6
@@ -187,9 +325,45 @@
187 325 $result[ $field_key ] = $sanitized_data;
188 326 }
189 327
190 328 return $result;
329 + }
191 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 '';
192 366 }
193 367
194 368 /**
195 369 * This function performs array_map for multi dimensional array
@@ -237,32 +411,85 @@
237 411 $markup = '';
238 412 $show_labels_as_placeholder = get_post_meta( self::get_integer_value( $form_id ), '_srfm_use_label_as_placeholder', true );
239 413 $show_labels_as_placeholder = $show_labels_as_placeholder ? self::get_string_value( $show_labels_as_placeholder ) : false;
240 414
415 + $required_sign = apply_filters( 'srfm_value_after_label_placeholder', ' *' );
416 +
417 + if ( ! is_string( $required_sign ) ) {
418 + $required_sign = ' *';
419 + }
420 +
241 421 switch ( $type ) {
242 422 case 'label':
243 - $markup = $label ? '<label for="srfm-' . $slug . '-' . esc_attr( $block_id ) . '" class="srfm-block-label">' . htmlspecialchars_decode( esc_html( $label ) ) . ( $required ? '<span class="srfm-required"> *</span>' : '' ) . '</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 + }
244 435 break;
245 436 case 'help':
246 - $markup = $help ? '<div class="srfm-description" id="srfm-description-' . esc_attr( $block_id ) . '">' . esc_html( $help ) . '</div>' : '';
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 + }
247 446 break;
248 447 case 'error':
249 - $markup = $required || $override ? '<div class="srfm-error-message" id="srfm-error-' . esc_attr( $block_id ) . '" data-error-msg="' . esc_attr( $error_msg ) . '"' . $duplicate_msg . '>' . esc_html( $error_msg ) . '</div>' : '';
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 + }
250 457 break;
251 458 case 'is_unique':
252 - $markup = $is_unique ? '<div class="srfm-error">' . esc_html( $duplicate_msg ) . '</div>' : '';
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 + }
253 468 break;
254 469 case 'placeholder':
255 - $markup = $label && '1' === $show_labels_as_placeholder ? $label . ( $required ? ' *' : '' ) : '';
470 + $markup = $label && '1' === $show_labels_as_placeholder ? esc_html( $label ) . ( $required ? esc_attr( $required_sign ) : '' ) : '';
256 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;
257 485 default:
258 486 $markup = '';
259 487 }
260 488
261 - return $markup;
489 + return is_string( $markup ) ? $markup : '';
262 490 }
263 491
264 -
265 492 /**
266 493 * Get an SVG Icon
267 494 *
268 495 * @since 0.0.1
@@ -273,9 +500,8 @@
273 500 */
274 501 public static function fetch_svg( $icon = '', $class = '', $html = '' ) {
275 502 $class = $class ? ' ' . $class : '';
276 503
277 - $output = '<span class="srfm-icon' . $class . '" ' . $html . '>';
278 504 if ( ! self::$srfm_svgs ) {
279 505 ob_start();
280 506
281 507 include_once SRFM_DIR . 'assets/svg/svgs.json';
@@ -282,54 +508,112 @@
282 508 self::$srfm_svgs = json_decode( self::get_string_value( ob_get_clean() ), true );
283 509 self::$srfm_svgs = apply_filters( 'srfm_svg_icons', self::$srfm_svgs );
284 510 }
285 511
286 - $output .= isset( self::$srfm_svgs[ $icon ] ) ? self::$srfm_svgs[ $icon ] : '';
287 - $output .= '</span>';
288 -
289 - return $output;
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 : '';
290 520 }
291 521
292 -
293 522 /**
294 - * Encrypt data using base64.
523 + * Base64-encode a string for use inside a field key.
295 524 *
296 - * @param string $input The input string which needs to be encrypted.
297 - * @since 0.0.1
298 - * @return string The encrypted string.
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).
299 547 */
300 - public static function encrypt( $input ) {
548 + public static function encode( $input ) {
301 549 // If the input is empty or not a string, then abandon ship.
302 550 if ( empty( $input ) || ! is_string( $input ) ) {
303 551 return '';
304 552 }
305 553
306 - // Encrypt the input and return it.
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.
307 558 $base_64 = base64_encode( $input ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
308 - $encode = rtrim( $base_64, '=' );
309 - return $encode;
559 + return rtrim( $base_64, '=' );
310 560 }
311 561
312 562 /**
313 - * Decrypt data using base64.
563 + * Base64-encode a string.
314 564 *
315 - * @param string $input The input string which needs to be decrypted.
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.
316 569 * @since 0.0.1
317 - * @return string The decrypted string.
570 + * @return string The base64-encoded string.
318 571 */
319 - public static function decrypt( $input ) {
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 ) {
320 591 // If the input is empty or not a string, then abandon ship.
321 592 if ( empty( $input ) || ! is_string( $input ) ) {
322 593 return '';
323 594 }
324 595
325 - // Decrypt the input and return it.
596 + // Base64-decode the input and return it.
326 597 $base_64 = $input . str_repeat( '=', strlen( $input ) % 4 );
327 - $decode = base64_decode( $base_64 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
328 - return $decode;
598 + return base64_decode( $base_64 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
329 599 }
330 600
331 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 + /**
332 616 * Update an option from the database.
333 617 *
334 618 * @param string $key The option key.
335 619 * @param mixed $value The value to update.
@@ -360,10 +644,9 @@
360 644 // Give priority to live mode data if we have one set from the Instant Form.
361 645 return self::get_string_value( $srfm_live_mode_data[ $key ] );
362 646 }
363 647
364 - $meta_value = 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 );
365 - return $meta_value;
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 );
366 649 }
367 650
368 651 /**
369 652 * Wrapper for the WordPress's get_post_meta function with the support for default values.
@@ -370,9 +653,9 @@
370 653 *
371 654 * @param int|string $post_id Post ID.
372 655 * @param string $key The meta key to retrieve.
373 656 * @param mixed $default Default value.
374 - * @param boolean $single Optional. Whether to return a single value.
657 + * @param bool $single Optional. Whether to return a single value.
375 658 * @since 0.0.8
376 659 * @return mixed Meta value.
377 660 */
378 661 public static function get_post_meta( $post_id, $key, $default = null, $single = true ) {
@@ -386,13 +669,13 @@
386 669 * @since 0.0.8
387 670 * @return array<mixed> Live preview data.
388 671 */
389 672 public static function get_instant_form_live_data() {
390 - $srfm_live_mode_data = isset( $_GET['live_mode'] ) && current_user_can( 'edit_posts' ) ? self::sanitize_recursively( 'sanitize_text_field', wp_unslash( $_GET ) ) : []; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
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.
391 674
392 675 return $srfm_live_mode_data ? array_map(
393 676 // Normalize falsy values.
394 - function( $live_data ) {
677 + static function( $live_data ) {
395 678 return 'false' === $live_data ? false : $live_data;
396 679 },
397 680 $srfm_live_mode_data
398 681 ) : [];
@@ -397,14 +680,13 @@
397 680 $srfm_live_mode_data
398 681 ) : [];
399 682 }
400 683
401 -
402 684 /**
403 685 * Default dynamic block value.
404 686 *
405 687 * @since 0.0.1
406 - * @return string[] Meta value.
688 + * @return array<string> Meta value.
407 689 */
408 690 public static function default_dynamic_block_option() {
409 691
410 692 $common_err_msg = self::get_common_err_msg();
@@ -426,10 +708,11 @@
426 708 'srfm_dropdown_block_required_text' => $common_err_msg['required'],
427 709 'srfm_rating_block_required_text' => $common_err_msg['required'],
428 710 ];
429 711
712 + $default_values = array_merge( $default_values, Translatable::dynamic_validation_messages() );
713 +
430 714 return apply_filters( 'srfm_default_dynamic_block_option', $default_values, $common_err_msg );
431 -
432 715 }
433 716
434 717 /**
435 718 * Get default dynamic block value.
@@ -439,15 +722,14 @@
439 722 * @return string Meta value.
440 723 */
441 724 public static function get_default_dynamic_block_option( $key ) {
442 725 $default_dynamic_values = self::default_dynamic_block_option();
443 - $option = get_option( 'get_default_dynamic_block_option', $default_dynamic_values );
726 + $option = get_option( 'srfm_default_dynamic_block_option', $default_dynamic_values );
444 727
445 728 if ( is_array( $option ) && array_key_exists( $key, $option ) ) {
446 729 return $option[ $key ];
447 - } else {
730 + }
448 731 return '';
449 - }
450 732 }
451 733
452 734 /**
453 735 * Checks whether a given request has appropriate permissions.
@@ -455,23 +737,12 @@
455 737 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
456 738 * @since 0.0.1
457 739 */
458 740 public static function get_items_permissions_check() {
459 - if ( current_user_can( 'edit_posts' ) ) {
741 + if ( self::current_user_can() ) {
460 742 return true;
461 743 }
462 744
463 - foreach ( get_post_types( [ 'show_in_rest' => true ], 'objects' ) as $post_type ) {
464 - /**
465 - * The post type.
466 - *
467 - * @var WP_Post_Type $post_type
468 - */
469 - if ( current_user_can( $post_type->cap->edit_posts ) ) {
470 - return true;
471 - }
472 - }
473 -
474 745 return new WP_Error(
475 746 'rest_cannot_view',
476 747 __( 'Sorry, you are not allowed to perform this action.', 'sureforms' ),
477 748 [ 'status' => \rest_authorization_required_code() ]
@@ -478,25 +749,63 @@
478 749 );
479 750 }
480 751
481 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 + /**
482 788 * Check if the current user has a given capability.
483 789 *
484 - * @param string $capability The capability to check.
790 + * @param string $capability The capability to check.
791 + * @param array<mixed> $args Optional. Additional arguments to pass to the capability check.
792 + *
485 793 * @since 0.0.3
486 794 * @return bool Whether the current user has the given capability or role.
487 795 */
488 - public static function current_user_can( $capability = '' ) {
489 -
796 + public static function current_user_can( $capability = '', $args = [] ) {
490 797 if ( ! function_exists( 'current_user_can' ) ) {
491 798 return false;
492 799 }
493 800
494 801 if ( ! is_string( $capability ) || empty( $capability ) ) {
495 - $capability = 'edit_posts';
802 + $capability = 'manage_options';
496 803 }
497 804
498 - return current_user_can( $capability );
805 + return ! empty( $args ) && is_array( $args ) && count( $args ) > 0
806 + ? current_user_can( $capability, ...$args )
807 + : current_user_can( $capability );
499 808 }
500 809
501 810 /**
502 811 * Get all the entries for the given form ids. The entries are older than the given days_old.
@@ -503,41 +812,38 @@
503 812 *
504 813 * @param int $days_old The number of days old the entries should be.
505 814 * @param array<int> $sf_form_ids The form ids for which the entries need to be fetched.
506 815 * @since 0.0.2
507 - * @return array<int|WP_Post> the entries matching the criteria.
816 + * @return array<mixed> the entries matching the criteria.
508 817 */
509 818 public static function get_entries_from_form_ids( $days_old = 0, $sf_form_ids = [] ) {
510 819
511 - $entries = [];
820 + $entries = [];
821 + $days_old_date = ( new \DateTime() )->modify( "-{$days_old} days" )->format( 'Y-m-d H:i:s' );
512 822
513 823 foreach ( $sf_form_ids as $form_id ) {
824 + // args according to the get_all() function in the Entries class.
514 825 $args = [
515 - 'post_type' => 'sureforms_entry',
516 - 'post_status' => 'publish',
517 - 'date_query' => [
826 + 'where' => [
518 827 [
519 - 'before' => $days_old . ' days ago',
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 + ],
520 838 ],
521 839 ],
522 - 'meta_query' // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query. -- We require meta_query for this function to work.
523 - => [
524 - [
525 - 'key' => '_srfm_entry_form_id',
526 - 'value' => $form_id,
527 - 'compare' => '=',
528 - ],
529 - ],
530 840 ];
531 841
532 - $query = new WP_Query( $args );
533 -
534 - // store all the entries in an single array.
535 - $entries = array_merge( $entries, $query->posts );
842 + // store all the entries in a single array.
843 + $entries = array_merge( $entries, Entries::get_all( $args, false ) );
536 844 }
537 -
538 845 return $entries;
539 -
540 846 }
541 847
542 848 /**
543 849 * Decode block attributes.
@@ -569,11 +875,74 @@
569 875 foreach ( $submission_data as $key => $value ) {
570 876 if ( false === strpos( $key, '-lbl-' ) ) {
571 877 continue;
572 878 }
573 - $label = explode( '-lbl-', $key )[1];
574 - $slug = implode( '-', array_slice( explode( '-', $label ), 1 ) );
575 - $mapped_data[ $slug ] = $value;
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;
576 945 }
577 946 return $mapped_data;
578 947 }
579 948
@@ -638,14 +1007,14 @@
638 1007 * @return array<string|mixed>
639 1008 */
640 1009 public static function get_css_vars( $field_spacing = null ) {
641 1010 /**
642 - * $sizes - Field Spacing Sizes Variables.
643 - * The array contains the CSS variables for different field spacing sizes.
644 - * Each key corresponds to the field spacing size, and the value is an array of CSS variables.
645 - *
646 - * For future variables depending on the field spacing size, add the variable to the array respectively.
647 - */
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 + */
648 1017 $sizes = apply_filters(
649 1018 'srfm_css_vars_sizes',
650 1019 [
651 1020 'small' => [
@@ -650,8 +1019,12 @@
650 1019 [
651 1020 'small' => [
652 1021 '--srfm-row-gap-between-blocks' => '16px',
653 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',
654 1027 '--srfm-col-gap-between-fields' => '12px',
655 1028 '--srfm-row-gap-between-fields' => '12px',
656 1029 '--srfm-gap-below-address-label' => '12px',
657 1030 // Dropdown Variables.
@@ -668,10 +1041,15 @@
668 1041 '--srfm-input-height' => '40px',
669 1042 '--srfm-input-field-padding' => '10px 12px',
670 1043 '--srfm-input-field-font-size' => '14px',
671 1044 '--srfm-input-field-line-height' => '20px',
672 - '--srfm-input-field-margin' => '4px 0',
1045 + '--srfm-input-field-margin-top' => '4px',
1046 + '--srfm-input-field-margin-bottom' => '4px',
673 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',
674 1052 '--srfm-check-ctn-width' => '16px',
675 1053 '--srfm-check-ctn-height' => '16px',
676 1054 '--srfm-check-svg-size' => '10px',
677 1055 '--srfm-checkbox-margin-top-frontend' => '2px',
@@ -702,8 +1080,12 @@
702 1080 ],
703 1081 'medium' => [
704 1082 '--srfm-row-gap-between-blocks' => '18px',
705 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',
706 1088 '--srfm-col-gap-between-fields' => '16px',
707 1089 '--srfm-row-gap-between-fields' => '16px',
708 1090 '--srfm-gap-below-address-label' => '14px',
709 1091 // Input Field Variables.
@@ -709,10 +1091,15 @@
709 1091 // Input Field Variables.
710 1092 '--srfm-input-height' => '44px',
711 1093 '--srfm-input-field-font-size' => '16px',
712 1094 '--srfm-input-field-line-height' => '24px',
713 - '--srfm-input-field-margin' => '6px 0',
1095 + '--srfm-input-field-margin-top' => '6px',
1096 + '--srfm-input-field-margin-bottom' => '6px',
714 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',
715 1102 '--srfm-checkbox-margin-top-frontend' => '4px',
716 1103 '--srfm-checkbox-margin-top-editor' => '6px',
717 1104 '--srfm-checkbox-description-margin-left' => '24px',
718 1105 // Label Variables.
@@ -735,8 +1122,12 @@
735 1122 ],
736 1123 'large' => [
737 1124 '--srfm-row-gap-between-blocks' => '20px',
738 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',
739 1130 '--srfm-col-gap-between-fields' => '16px',
740 1131 '--srfm-row-gap-between-fields' => '20px',
741 1132 '--srfm-gap-below-address-label' => '16px',
742 1133 // Dropdown Variables.
@@ -750,10 +1141,15 @@
750 1141 '--srfm-input-height' => '48px',
751 1142 '--srfm-input-field-padding' => '10px 14px',
752 1143 '--srfm-input-field-font-size' => '18px',
753 1144 '--srfm-input-field-line-height' => '28px',
754 - '--srfm-input-field-margin' => '8px 0',
1145 + '--srfm-input-field-margin-top' => '8px',
1146 + '--srfm-input-field-margin-bottom' => '8px',
755 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',
756 1152 '--srfm-check-ctn-width' => '20px',
757 1153 '--srfm-check-ctn-height' => '20px',
758 1154 '--srfm-check-svg-size' => '14px',
759 1155 '--srfm-check-gap' => '10px',
@@ -816,22 +1212,90 @@
816 1212 'srfm/multi-choice',
817 1213 'srfm/radio',
818 1214 'srfm/submit',
819 1215 'srfm/url',
1216 + 'srfm/payment',
820 1217 ]
821 1218 );
822 1219 }
823 1220
824 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 + /**
825 1289 * Process blocks and inner blocks.
826 1290 *
827 - * @param array<array<array<mixed>>> $blocks The block data.
828 - * @param array<string> $slugs The array of existing slugs.
829 - * @param bool $updated The array of existing slugs.
830 - * @param string $prefix The array of existing slugs.
831 - * @param boolean $skip_checking_existing_slug Skips the checking of existing slug if passed true. More information documented inside this function.
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.
832 1296 * @since 0.0.10
833 - * @return array{array<array<array<mixed>>>,array<string>,bool}
1297 + * @return array
834 1298 */
835 1299 public static function process_blocks( $blocks, &$slugs, &$updated, $prefix = '', $skip_checking_existing_slug = false ) {
836 1300
837 1301 if ( ! is_array( $blocks ) ) {
@@ -855,8 +1319,12 @@
855 1319 if ( isset( $block['attrs'] ) && ! empty( $block['attrs']['slug'] ) && ! in_array( $block['attrs']['slug'], $slugs, true ) ) {
856 1320
857 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.
858 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 + }
859 1327 continue;
860 1328 }
861 1329
862 1330 if ( $skip_checking_existing_slug && empty( $block['innerBlocks'] ) && isset( $slugs[ $block['attrs']['block_id'] ] ) ) {
@@ -880,9 +1348,9 @@
880 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.
881 1349 $updated = true;
882 1350 if ( is_array( $block['innerBlocks'] ) && ! empty( $block['innerBlocks'] ) ) {
883 1351
884 - list( $blocks[ $index ]['innerBlocks'], $slugs, $updated ) = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $blocks[ $index ]['attrs']['slug'] );
1352 + [ $blocks[ $index ]['innerBlocks'], $slugs, $updated ] = self::process_blocks( $block['innerBlocks'], $slugs, $updated, $blocks[ $index ]['attrs']['slug'] );
885 1353
886 1354 }
887 1355 }
888 1356 }
@@ -902,8 +1370,19 @@
902 1370 $slug = is_string( $block['blockName'] ) ? $block['blockName'] : '';
903 1371
904 1372 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
905 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 + }
906 1385 }
907 1386
908 1387 if ( ! empty( $prefix ) ) {
909 1388 $slug = $prefix . '-' . $slug;
@@ -908,11 +1387,9 @@
908 1387 if ( ! empty( $prefix ) ) {
909 1388 $slug = $prefix . '-' . $slug;
910 1389 }
911 1390
912 - $slug = self::generate_slug( $slug, $slugs );
913 -
914 - return $slug;
1391 + return self::generate_slug( $slug, $slugs );
915 1392 }
916 1393
917 1394 /**
918 1395 * This function ensures that the slug is unique.
@@ -947,7 +1424,1468 @@
947 1424 * @return string|false The JSON representation of the value on success or false on failure.
948 1425 */
949 1426 public static function encode_json( $data ) {
950 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 + * Whether SureForms promotional content should be hidden.
2148 + *
2149 + * Covers review requests, cross-sell banners and announcements. The free
2150 + * plugin never hides them on its own; SureForms Pro's Distraction Free mode
2151 + * turns this on through the filter.
2152 + *
2153 + * @since 2.12.8
2154 + * @return bool
2155 + */
2156 + public static function hide_promotions() {
2157 + /**
2158 + * Filter whether SureForms hides its promotional content in wp-admin.
2159 + *
2160 + * @since 2.12.8
2161 + *
2162 + * @param bool $hide Default false.
2163 + */
2164 + return (bool) apply_filters( 'srfm_hide_promotions', false );
2165 + }
2166 +
2167 + /**
2168 + * Verifies the request by checking the nonce and user capabilities.
2169 + *
2170 + * @param string $request_type The type of request, either 'rest' or 'ajax'.
2171 + * @param string $nonce_action The action name for the nonce.
2172 + * @param string $nonce_name The name of the nonce field.
2173 + * @param string $capability The capability required to perform the action. Default is 'manage_options'.
2174 + *
2175 + * @since 1.10.0
2176 + * @return void
2177 + */
2178 + public static function verify_nonce_and_capabilities( $request_type, $nonce_action, $nonce_name, $capability = 'manage_options' ) {
2179 +
2180 + if ( ! is_string( $nonce_action ) || ! is_string( $nonce_name ) || empty( $nonce_action ) || empty( $nonce_name ) ) {
2181 + wp_send_json_error(
2182 + [ 'message' => __( 'Invalid nonce action or name.', 'sureforms' ) ],
2183 + 400
2184 + );
2185 + }
2186 +
2187 + // Verify nonce for security.
2188 + if ( 'rest' === $request_type ) {
2189 + // For REST API requests, use the WP_REST_Request object to verify the nonce.
2190 + if ( ! wp_verify_nonce( $nonce_action, $nonce_name ) ) {
2191 + wp_send_json_error(
2192 + [ 'message' => __( 'Invalid security token.', 'sureforms' ) ],
2193 + 403
2194 + );
2195 + }
2196 + } elseif ( 'ajax' === $request_type ) {
2197 + // For non-REST requests, use the standard nonce verification.
2198 + if ( ! check_ajax_referer( $nonce_action, $nonce_name, false ) ) {
2199 + wp_send_json_error(
2200 + [ 'message' => __( 'Invalid security token.', 'sureforms' ) ],
2201 + 403
2202 + );
2203 + }
2204 + } else {
2205 + // If the request type is not recognized, return an error.
2206 + wp_send_json_error(
2207 + [ 'message' => __( 'Invalid request type.', 'sureforms' ) ],
2208 + 400
2209 + );
2210 + }
2211 +
2212 + // Check user capabilities.
2213 + if ( ! current_user_can( $capability ) ) {
2214 + wp_send_json_error(
2215 + [ 'message' => esc_html__( 'You do not have permission to perform this action.', 'sureforms' ) ],
2216 + 403
2217 + );
2218 + }
2219 + }
2220 +
2221 + /**
2222 + * Get the block name from a field name by extracting the first two parts.
2223 + *
2224 + * @param string $field_name The full field name (e.g., 'srfm-text-lbl-123').
2225 + *
2226 + * @since 1.11.0
2227 + * @return string The block name (e.g., 'srfm-text').
2228 + */
2229 + public static function get_block_name_from_field( $field_name ) {
2230 + return implode( '-', array_slice( explode( '-', explode( '-lbl-', $field_name )[0] ), 0, 2 ) );
2231 + }
2232 +
2233 + /**
2234 + * The active caching plugin, if there is one.
2235 + *
2236 + * Caching matters to SureForms because a cached page serves the same HTML to
2237 + * everyone: the submission token is embedded at render time, and an
2238 + * aggressively cached or JS-combining setup can serve a stale token or reorder
2239 + * the scripts a form depends on. This is what surfaces that to the site owner
2240 + * before it turns into "my form stopped working".
2241 + *
2242 + * @since 2.12.6
2243 + * @return string Human-readable plugin name, or '' when none is active.
2244 + */
2245 + public static function get_active_caching_plugin() {
2246 + $entry = self::get_active_caching_plugin_entry();
2247 +
2248 + return null === $entry ? '' : $entry[0];
2249 + }
2250 +
2251 + /**
2252 + * Setup guide for the active caching plugin.
2253 + *
2254 + * Six of the recognised plugins have a guide of their own; the rest, and any
2255 + * site with none detected, get the general one. Sending someone to a page that
2256 + * names the plugin they actually run is the difference between advice they can
2257 + * follow and advice they have to translate.
2258 + *
2259 + * Falls back to the general guide rather than returning nothing, so the notice
2260 + * always has somewhere to send them.
2261 + *
2262 + * @since 2.12.7
2263 + * @param string $medium Placement the link is rendered in, used as utm_medium.
2264 + * Two surfaces show this guide -- the dashboard notice and
2265 + * the onboarding step -- and a shared value would make the
2266 + * two indistinguishable in reporting, which is the whole
2267 + * point of the attribution.
2268 + * @return string Absolute documentation URL.
2269 + */
2270 + public static function get_caching_plugin_doc_url( $medium = 'form_checks_notice' ) {
2271 + $entry = self::get_active_caching_plugin_entry();
2272 + $slug = null === $entry || '' === $entry[1] ? 'how-to-set-up-sureforms-with-caching-plugins' : $entry[1];
2273 +
2274 + // Through the central builder rather than hardcoding the domain, so the
2275 + // link carries the same UTM attribution as every other doc link and a
2276 + // domain change is one edit. utm_content is the slug, so the caller can be
2277 + // told which guide people actually open.
2278 + return self::get_sureforms_website_url(
2279 + 'docs/' . $slug . '/',
2280 + [
2281 + 'utm_medium' => self::get_string_value( $medium ),
2282 + 'utm_content' => $slug,
2283 + ]
2284 + );
2285 + }
2286 +
2287 + /**
2288 + * Check if any of the top 10 popular WordPress SMTP plugins is active using array_intersect.
2289 + *
2290 + * @since 1.9.1
2291 + * @return bool True if any SMTP plugin is active, false otherwise.
2292 + */
2293 + public static function is_any_smtp_plugin_active() {
2294 + $smtp_plugins = [
2295 + 'wp-mail-smtp/wp_mail_smtp.php',
2296 + 'post-smtp/postman-smtp.php',
2297 + 'easy-wp-smtp/easy-wp-smtp.php',
2298 + 'wp-smtp/wp-smtp.php',
2299 + 'newsletter/plugin.php',
2300 + 'fluent-smtp/fluent-smtp.php',
2301 + 'pepipost-smtp/pepipost-smtp.php',
2302 + 'mail-bank/wp-mail-bank.php',
2303 + 'smtp-mailer/smtp-mailer.php',
2304 + 'suremails/suremails.php',
2305 + 'site-mailer/site-mailer.php',
2306 + ];
2307 +
2308 + $active_plugins = (array) get_option( 'active_plugins', [] );
2309 + // For multisite, merge sitewide active plugins.
2310 + if ( is_multisite() ) {
2311 + $network_plugins = (array) get_site_option( 'active_sitewide_plugins', [] );
2312 + $active_plugins = array_merge( $active_plugins, array_keys( $network_plugins ) );
2313 + }
2314 +
2315 + return (bool) array_intersect( $smtp_plugins, $active_plugins );
2316 + }
2317 +
2318 + /**
2319 + * Apply a filter and return the filtered value only if it's a non-empty array.
2320 + * Otherwise, return the default array.
2321 + *
2322 + * @param string $filter_name The name of the filter to apply.
2323 + * @param mixed $default The default array to return if the filtered result is invalid.
2324 + * @param mixed ...$args Additional arguments to pass to the filter.
2325 + *
2326 + * @return array The filtered array if valid, otherwise the default.
2327 + */
2328 + public static function apply_filters_as_array( $filter_name, $default, ...$args ) {
2329 + // Ensure $default is an array.
2330 + if ( ! is_array( $default ) ) {
2331 + $default = [];
2332 + }
2333 +
2334 + // Validate the filter name.
2335 + if ( ! is_string( $filter_name ) || empty( $filter_name ) ) {
2336 + return $default;
2337 + }
2338 +
2339 + // Apply the filter with additional arguments.
2340 + $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.
2341 +
2342 + // Return filtered result if it's a non-empty array.
2343 + return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default;
2344 + }
2345 +
2346 + /**
2347 + * Get forms with entry counts for a specific time period.
2348 + *
2349 + * @param int $timestamp The timestamp to get entries after.
2350 + * @param int $limit Maximum number of forms to return (0 for all).
2351 + * @param bool $sort Whether to sort by entry count descending.
2352 + * @return array Array of form data with entry counts.
2353 + * @since 1.9.1
2354 + */
2355 + public static function get_forms_with_entry_counts( $timestamp, $limit = 0, $sort = true ) {
2356 + // Get all published forms with post objects for bulk title access.
2357 + $args = [
2358 + 'post_type' => SRFM_FORMS_POST_TYPE,
2359 + 'posts_per_page' => -1,
2360 + 'post_status' => 'publish',
2361 + 'orderby' => 'ID',
2362 + 'order' => 'DESC',
2363 + 'no_found_rows' => true,
2364 + 'update_post_term_cache' => false,
2365 + 'update_post_meta_cache' => false,
2366 + ];
2367 +
2368 + $query = new \WP_Query( $args );
2369 +
2370 + if ( ! $query->have_posts() ) {
2371 + return [];
2372 + }
2373 +
2374 + $all_forms = [];
2375 +
2376 + // Process posts directly from the query results without touching global $post.
2377 + foreach ( $query->posts as $form ) {
2378 + // Ensure we have a valid post object.
2379 + if ( ! $form instanceof \WP_Post ) {
2380 + continue;
2381 + }
2382 +
2383 + $form_id = (int) $form->ID;
2384 + if ( $form_id <= 0 ) {
2385 + continue;
2386 + }
2387 +
2388 + // Get entries count after the timestamp for this specific form.
2389 + $entry_count = Entries::get_entries_count_after( $timestamp, $form_id );
2390 +
2391 + // Get form title directly from post object, use "Blank Form" if empty.
2392 + $form_title = $form->post_title;
2393 + if ( empty( trim( self::get_string_value( $form_title ) ) ) ) {
2394 + $form_title = __( 'Blank Form', 'sureforms' );
2395 + }
2396 +
2397 + $all_forms[] = [
2398 + 'form_id' => $form_id,
2399 + 'title' => $form_title,
2400 + 'count' => $entry_count,
2401 + ];
2402 + }
2403 +
2404 + // Sort by count descending, then by form_id descending for consistency.
2405 + if ( $sort ) {
2406 + usort(
2407 + $all_forms,
2408 + static function( $a, $b ) {
2409 + if ( $a['count'] === $b['count'] ) {
2410 + return $b['form_id'] - $a['form_id'];
2411 + }
2412 + return $b['count'] - $a['count'];
2413 + }
2414 + );
2415 + }
2416 +
2417 + // Return limited results if specified.
2418 + if ( $limit > 0 ) {
2419 + return array_slice( $all_forms, 0, $limit );
2420 + }
2421 +
2422 + return $all_forms;
2423 + }
2424 +
2425 + /**
2426 + * Check if the given form ID is valid SureForms form ID.
2427 + * A valid form ID is a numeric value that corresponds to an existing SureForms form in the database.
2428 + *
2429 + * @since 1.9.1
2430 + *
2431 + * @param int|string|mixed $form_id The form ID to validate.
2432 + * @return bool True if the form ID is valid, false otherwise.
2433 + */
2434 + public static function is_valid_form( $form_id ) {
2435 +
2436 + // Check for a valid form ID.
2437 + if ( empty( $form_id ) || ! is_numeric( $form_id ) ) {
2438 + return false;
2439 + }
2440 +
2441 + // Check if the form ID exists in the database.
2442 + $form = get_post( self::get_integer_value( $form_id ) );
2443 +
2444 + // If the form does not exist or is not of the correct post type, return false.
2445 + if ( ! $form || ! is_a( $form, 'WP_Post' ) || SRFM_FORMS_POST_TYPE !== $form->post_type ) {
2446 + return false;
2447 + }
2448 +
2449 + return true;
2450 + }
2451 +
2452 + /**
2453 + * Get the timestamp from a string.
2454 + *
2455 + * This function uses WordPress's configured timezone (from Settings → General → Timezone)
2456 + * to ensure consistent behavior regardless of the server's timezone settings.
2457 + *
2458 + * @param string $date The date in YYYY-MM-DD format (e.g., '2026-01-10').
2459 + * @param string $hours The hours in 12-hour format (e.g., '12', '01'-'12').
2460 + * @param string $minutes The minutes (e.g., '00', '00'-'59').
2461 + * @param string $meridiem The meridiem (e.g., 'AM' or 'PM').
2462 + *
2463 + * @since 1.10.1
2464 + * @return int|false The timestamp if successful, false otherwise.
2465 + */
2466 + public static function get_timestamp_from_string( $date, $hours = '12', $minutes = '00', $meridiem = 'AM' ) {
2467 +
2468 + if ( empty( $date ) || ! is_string( $date ) ) {
2469 + return false; // Invalid input.
2470 + }
2471 +
2472 + // Ensure the date is in a valid format of YYYY-MM-DD.
2473 + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
2474 + return false; // Invalid date format.
2475 + }
2476 +
2477 + $time_string = $date . ' ' . $hours . ':' . $minutes . ' ' . $meridiem;
2478 +
2479 + // Convert to timestamp using WordPress timezone.
2480 + // This ensures the date/time is interpreted in the site's configured timezone,
2481 + // not the server's timezone or PHP's default timezone.
2482 + try {
2483 + $datetime = date_create( $time_string, wp_timezone() );
2484 +
2485 + if ( false === $datetime ) {
2486 + return false;
2487 + }
2488 +
2489 + $timestamp = $datetime->getTimestamp();
2490 +
2491 + if ( is_int( $timestamp ) && $timestamp > 0 ) {
2492 + return $timestamp;
2493 + }
2494 + } catch ( \Exception $e ) {
2495 + // If timezone conversion fails, return false.
2496 + return false;
2497 + }
2498 +
2499 + // If conversion fails, return false.
2500 + return false;
2501 + }
2502 +
2503 + /**
2504 + * Generate a unique ID for the saved form.
2505 + * Also ensures that the generated ID does not already exist in the database table.
2506 + *
2507 + * @param class-string $class The class name where the get method is defined to check for existing IDs.
2508 + * @param int<1, max> $length The length of the random bytes to generate. Default is 8.
2509 + * @return string
2510 + * @since 2.2.0
2511 + */
2512 + public static function generate_unique_id( $class, $length = 8 ) {
2513 + // Ensure length is at least 1.
2514 + $length = max( 1, $length );
2515 +
2516 + do {
2517 + $id = bin2hex( random_bytes( $length ) );
2518 + } while ( is_callable( [ $class, 'get' ] ) && call_user_func( [ $class, 'get' ], $id ) );
2519 + return $id;
2520 + }
2521 +
2522 + /**
2523 + * Log error messages to the error log.
2524 + *
2525 + * This function checks if error_log function exists, validates the message,
2526 + * and logs it with the print_r second argument set to true.
2527 + *
2528 + * Logging is disabled by default. To enable logging, add this to wp-config.php:
2529 + * define( 'SRFM_LOG', true );
2530 + *
2531 + * @param mixed $message The error message to log. Can be string or any type.
2532 + * @param string $prefix Optional prefix to add before the message. Default: 'Log :'.
2533 + *
2534 + * @since 2.0.0
2535 + * @return void
2536 + */
2537 + public static function srfm_log( $message, $prefix = 'Log :' ) {
2538 + // Check if logging is enabled via SRFM_LOG constant.
2539 + if ( ! defined( 'SRFM_LOG' ) ) {
2540 + return;
2541 + }
2542 + unset( $message, $prefix );
2543 + }
2544 +
2545 + /**
2546 + * Encodes data to base64 after JSON encoding with validation.
2547 + *
2548 + * This function checks if the data is non-empty and valid for JSON encoding.
2549 + * If data is not valid, returns an empty string.
2550 + * Otherwise, it attempts to JSON encode and then base64 encode the result.
2551 + *
2552 + * @param mixed $data The data to JSON encode and then base64 encode.
2553 + * @return string The base64-encoded JSON string, or empty string on failure.
2554 + */
2555 + public static function srfm_base64_json_encode( $data ) {
2556 + if ( empty( $data ) || ! is_array( $data ) ) {
2557 + return '';
2558 + }
2559 +
2560 + $json = wp_json_encode( $data );
2561 + if ( false === $json || '' === $json ) {
2562 + return '';
2563 + }
2564 +
2565 + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
2566 + return base64_encode( $json );
2567 + }
2568 +
2569 + /**
2570 + * Get the visitor's IP address.
2571 + *
2572 + * Centralised IP detection that checks common proxy headers before
2573 + * falling back to REMOTE_ADDR. Handles comma-separated IPs that
2574 + * load-balancers / CDNs may append (takes the first, i.e. client IP).
2575 + *
2576 + * NOTE: Existing callers (Smart_Tags::get_the_user_ip, Front_End::get_user_ip,
2577 + * inline reads in Form_Submit) can be migrated to this method in the future
2578 + * to avoid duplicating the same header-chain logic.
2579 + *
2580 + * @since 2.8.0
2581 + * @return string Validated IP address, or empty string if unavailable.
2582 + */
2583 + public static function get_visitor_ip() {
2584 + $headers = [
2585 + 'HTTP_CLIENT_IP',
2586 + 'HTTP_X_FORWARDED_FOR',
2587 + 'HTTP_X_REAL_IP',
2588 + 'HTTP_X_FORWARDED',
2589 + 'HTTP_FORWARDED_FOR',
2590 + 'HTTP_FORWARDED',
2591 + 'REMOTE_ADDR',
2592 + ];
2593 +
2594 + foreach ( $headers as $header ) {
2595 + if ( empty( $_SERVER[ $header ] ) ) {
2596 + continue;
2597 + }
2598 +
2599 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by FILTER_VALIDATE_IP below.
2600 + $raw = wp_unslash( $_SERVER[ $header ] );
2601 +
2602 + // Proxies may send comma-separated IPs; the first is the original client.
2603 + if ( false !== strpos( $raw, ',' ) ) {
2604 + $raw = trim( explode( ',', $raw )[0] );
2605 + }
2606 +
2607 + $ip = filter_var( $raw, FILTER_VALIDATE_IP );
2608 + if ( false !== $ip ) {
2609 + /**
2610 + * Filters the detected visitor IP address.
2611 + *
2612 + * @since 2.8.0
2613 + *
2614 + * @param string $ip Validated IP address.
2615 + */
2616 + return apply_filters( 'srfm_visitor_ip', $ip );
2617 + }
2618 + }
2619 +
2620 + return '';
2621 + }
2622 +
2623 + /**
2624 + * Detect the visitor's 2-letter country code via server-side IP geolocation.
2625 + *
2626 + * Prefers a CDN/server-provided country header (Cloudflare, CloudFront, mod_geoip)
2627 + * when present — free, instant and cache-safe. Otherwise calls ipapi.co once per
2628 + * visitor IP and caches the result in a transient for 24 hours so subsequent
2629 + * lookups for the same IP resolve instantly. Failures are cached for a short TTL
2630 + * (see get_geo_failure_ttl()) to avoid retry storms while still self-healing, and
2631 + * a site-wide hourly cap (filterable via `srfm_geo_api_hourly_cap`, default 40)
2632 + * bounds outbound calls. Private/reserved IPs are rejected up front.
2633 + *
2634 + * Intended to be called per-visitor (e.g. via the geo-country REST route) so
2635 + * the result is correct on full-page-cached sites instead of being baked into
2636 + * the cached HTML.
2637 + *
2638 + * Local testing: private/loopback IPs (e.g. 127.0.0.1) cannot be geolocated, so
2639 + * inject a public IP via the `srfm_visitor_ip` filter to exercise detection:
2640 + *
2641 + * add_filter( 'srfm_visitor_ip', static fn() => '8.8.8.8' ); // US; try 1.1.1.1 etc.
2642 + *
2643 + * @param string $fallback Country code returned when detection is unavailable.
2644 + * @since 2.11.1
2645 + * @return string Lowercase 2-letter country code.
2646 + */
2647 + public static function get_geo_country( $fallback = 'us' ) {
2648 + // Prefer a CDN/server-provided country header — free, instant, per-visitor
2649 + // and cache-safe. It is independent of the connecting IP (it still resolves
2650 + // when the visitor IP is private/loopback, e.g. local dev or behind a
2651 + // proxy), so it must be checked before the IP-based path below.
2652 + $cdn_country = self::get_cdn_country();
2653 + if ( '' !== $cdn_country ) {
2654 + return $cdn_country;
2655 + }
2656 +
2657 + $ip = self::get_visitor_ip();
2658 + if ( empty( $ip ) ) {
2659 + return $fallback;
2660 + }
2661 +
2662 + // Reject private/reserved IPs: ipapi.co cannot geolocate them, and accepting
2663 + // them would let spoofed X-Forwarded-For headers flood the transient cache.
2664 + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
2665 + return $fallback;
2666 + }
2667 +
2668 + // Versioned key (v2) so stale failure transients written by older code
2669 + // (which used the byte-identical 'srfm_geo_' key and a 1h TTL) don't shadow
2670 + // the current CDN/fallback logic for the failure-TTL window after an upgrade.
2671 + $cache_key = 'srfm_geo_v2_' . md5( $ip );
2672 + $cached = get_transient( $cache_key );
2673 + if ( is_string( $cached ) && '' !== $cached ) {
2674 + return $cached;
2675 + }
2676 +
2677 + // Site-wide hourly cap on outbound ipapi calls; the counter rolls over
2678 + // every hour (key includes YmdH) so it never needs an explicit reset.
2679 + $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' );
2680 + $quota_cap = self::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) );
2681 + $count = self::get_integer_value( get_transient( $quota_key ) );
2682 + if ( $count >= $quota_cap ) {
2683 + self::srfm_log( $quota_cap, 'SRFM geo lookup skipped (hourly cap reached):' );
2684 + // Do NOT cache a per-IP transient here: the hourly counter already
2685 + // blocks outbound calls, and writing per IP is the one path not bounded
2686 + // by the cap — it would let spoofed X-Forwarded-For headers churn
2687 + // wp_options / the object cache under sustained traffic.
2688 + return $fallback;
2689 + }
2690 + set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS );
2691 +
2692 + // Pass the visitor's IP explicitly via /{ip}/json/ — the request originates
2693 + // from the server, so the bare /json/ endpoint would return the host's country.
2694 + $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/';
2695 +
2696 + // ipapi.co's free (keyless) tier is heavily rate-limited, so unauthenticated
2697 + // lookups are best-effort and often fail to the configured fallback. Sites
2698 + // that need reliable IP-based detection can supply a paid ipapi.co key via
2699 + // this filter; CDN-fronted sites resolve earlier via get_cdn_country() and
2700 + // never reach this call.
2701 + $api_key = self::get_string_value( apply_filters( 'srfm_ipapi_api_key', '' ) );
2702 + if ( '' !== $api_key ) {
2703 + $url = add_query_arg( 'key', rawurlencode( $api_key ), $url );
2704 + }
2705 +
2706 + $response = wp_remote_get(
2707 + $url,
2708 + [
2709 + 'timeout' => 3,
2710 + 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)',
2711 + ]
2712 + );
2713 +
2714 + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
2715 + $detail = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response );
2716 + self::srfm_log( $detail, 'SRFM geo lookup failed (transport):' );
2717 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2718 + return $fallback;
2719 + }
2720 +
2721 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
2722 +
2723 + // ipapi.co's free tier returns HTTP 200 with a JSON error body
2724 + // (e.g. {"error":true,"reason":"RateLimited"}) when throttled — treat as a failure.
2725 + if ( is_array( $body ) && ! empty( $body['error'] ) ) {
2726 + $reason = ! empty( $body['reason'] ) && is_string( $body['reason'] ) ? $body['reason'] : 'unknown';
2727 + self::srfm_log( $reason, 'SRFM geo lookup failed (ipapi error):' );
2728 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2729 + return $fallback;
2730 + }
2731 +
2732 + if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) {
2733 + self::srfm_log( wp_remote_retrieve_response_code( $response ), 'SRFM geo lookup failed (no country_code):' );
2734 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2735 + return $fallback;
2736 + }
2737 +
2738 + $country = strtolower( $body['country_code'] );
2739 +
2740 + // Validate the external API response is a valid 2-letter country code.
2741 + if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) {
2742 + self::srfm_log( $country, 'SRFM geo lookup failed (invalid country code):' );
2743 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2744 + return $fallback;
2745 + }
2746 +
2747 + set_transient( $cache_key, $country, DAY_IN_SECONDS );
2748 +
2749 + return $country;
2750 + }
2751 +
2752 + /**
2753 + * Read a visitor country code from a CDN / server geo header, if present.
2754 + *
2755 + * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that
2756 + * injects the visitor's country as a request header. This is free, instant,
2757 + * per-visitor and works on full-page-cached sites, so we prefer it over an
2758 + * outbound API call. Returns '' when no usable header is present.
2759 + *
2760 + * NOTE: these headers are client-spoofable when the site is NOT actually behind
2761 + * the named CDN/proxy. The value is used only as a phone-field UI default (the
2762 + * pre-selected flag), never for access control, so spoofing has no security
2763 + * impact here — at worst a visitor sees a different default country.
2764 + *
2765 + * @since 2.11.1
2766 + * @return string Lowercase 2-letter country code, or '' when unavailable.
2767 + */
2768 + private static function get_cdn_country() {
2769 + $headers = [
2770 + 'HTTP_CF_IPCOUNTRY', // Cloudflare.
2771 + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront.
2772 + 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind.
2773 + 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies.
2774 + 'HTTP_X_COUNTRY_CODE', // Some CDNs.
2775 + ];
2776 +
2777 + foreach ( $headers as $header ) {
2778 + if ( empty( $_SERVER[ $header ] ) ) {
2779 + continue;
2780 + }
2781 +
2782 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below.
2783 + $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) );
2784 +
2785 + // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values.
2786 + if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) {
2787 + return $code;
2788 + }
2789 + }
2790 +
2791 + /**
2792 + * Filters the CDN-derived visitor country code, before the ipapi.co fallback.
2793 + *
2794 + * Lets sites short-circuit detection with a server-side country code (e.g.
2795 + * from a custom header) without any outbound API call.
2796 + *
2797 + * @since 2.11.1
2798 + *
2799 + * @param string $code Lowercase 2-letter country code, or '' if none found.
2800 + */
2801 + return apply_filters( 'srfm_cdn_country', '' );
2802 + }
2803 +
2804 + /**
2805 + * TTL (in seconds) for caching a failed geo lookup.
2806 + *
2807 + * Short by default so a transient blip (rate-limit, timeout) self-heals on the
2808 + * next visit instead of pinning the fallback country for a full hour, while
2809 + * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`.
2810 + *
2811 + * @since 2.11.1
2812 + * @return int
2813 + */
2814 + private static function get_geo_failure_ttl() {
2815 + return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) );
2816 + }
2817 +
2818 + /**
2819 + * Caching plugins SureForms recognises, and the guide for each.
2820 + *
2821 + * `path => [ display name, doc slug ]`. An empty slug means there is no
2822 + * plugin-specific guide and the general one applies. Name and slug live in one
2823 + * array on purpose: keyed separately they drift, and a doc link that silently
2824 + * degrades to the generic page is the kind of regression nobody reports.
2825 + *
2826 + * Order is precedence: the first active plugin in this list wins. The six with
2827 + * their own guide are listed first on purpose, so a site running two caching
2828 + * plugins is pointed at the specific guide rather than whichever plugin the
2829 + * old alphabetical order happened to reach first. That flips the winner on a
2830 + * few pairs -- WP Fastest Cache over WP Super Cache, SiteGround Optimizer and
2831 + * Autoptimize over their partners -- and in each case the new winner is the
2832 + * one that has something to say. Reordering this array changes which guide a
2833 + * two-plugin site sees.
2834 + *
2835 + * @since 2.12.7
2836 + * @return array<string,array{0:string,1:string}>
2837 + */
2838 + private static function get_known_caching_plugins() {
2839 + return [
2840 + 'litespeed-cache/litespeed-cache.php' => [ 'LiteSpeed Cache', 'how-to-set-up-sureforms-with-litespeed-cache' ],
2841 + 'wp-rocket/wp-rocket.php' => [ 'WP Rocket', 'how-to-set-up-sureforms-with-wp-rocket' ],
2842 + 'w3-total-cache/w3-total-cache.php' => [ 'W3 Total Cache', 'how-to-set-up-sureforms-with-w3-total-cache' ],
2843 + 'wp-fastest-cache/wpFastestCache.php' => [ 'WP Fastest Cache', 'how-to-set-up-sureforms-with-wp-fastest-cache' ],
2844 + 'sg-cachepress/sg-cachepress.php' => [ 'SiteGround Optimizer', 'how-to-set-up-sureforms-with-siteground-optimizer' ],
2845 + 'autoptimize/autoptimize.php' => [ 'Autoptimize', 'how-to-set-up-sureforms-with-autoptimize' ],
2846 + 'wp-super-cache/wp-cache.php' => [ 'WP Super Cache', '' ],
2847 + 'wp-optimize/wp-optimize.php' => [ 'WP-Optimize', '' ],
2848 + 'cache-enabler/cache-enabler.php' => [ 'Cache Enabler', '' ],
2849 + 'comet-cache/comet-cache.php' => [ 'Comet Cache', '' ],
2850 + 'hummingbird-performance/wp-hummingbird.php' => [ 'Hummingbird', '' ],
2851 + 'breeze/breeze.php' => [ 'Breeze', '' ],
2852 + 'nitropack/main.php' => [ 'NitroPack', '' ],
2853 + 'swift-performance-lite/performance.php' => [ 'Swift Performance Lite', '' ],
2854 + 'wp-cloudflare-page-cache/wp-cloudflare-page-cache.php' => [ 'Super Page Cache', '' ],
2855 + 'flying-press/flying-press.php' => [ 'FlyingPress', '' ],
2856 + 'redis-cache/redis-cache.php' => [ 'Redis Object Cache', '' ],
2857 + 'powered-cache/powered-cache.php' => [ 'Powered Cache', '' ],
2858 + 'docket-cache/docket-cache.php' => [ 'Docket Cache', '' ],
2859 + 'seraphinite-accelerator/plugin_root.php' => [ 'Seraphinite Accelerator', '' ],
2860 + ];
2861 + }
2862 +
2863 + /**
2864 + * The active caching plugin's entry, if there is one.
2865 + *
2866 + * Detection is by plugin path, mirroring is_any_smtp_plugin_active(), including
2867 + * the multisite network-active merge. First match in
2868 + * get_known_caching_plugins() wins; that array's order is the precedence.
2869 + *
2870 + * @since 2.12.7
2871 + * @return array{0:string,1:string}|null Name and doc slug, or null when none is active.
2872 + */
2873 + private static function get_active_caching_plugin_entry() {
2874 + $active_plugins = (array) get_option( 'active_plugins', [] );
2875 +
2876 + // For multisite, merge sitewide active plugins.
2877 + if ( is_multisite() ) {
2878 + $network_plugins = (array) get_site_option( 'active_sitewide_plugins', [] );
2879 + $active_plugins = array_merge( $active_plugins, array_keys( $network_plugins ) );
2880 + }
2881 +
2882 + foreach ( self::get_known_caching_plugins() as $path => $entry ) {
2883 + if ( in_array( $path, $active_plugins, true ) ) {
2884 + return $entry;
2885 + }
2886 + }
2887 +
2888 + return null;
951 2889 }
952 2890
953 2891 }