PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.3
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.3
2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / field-validation.php
sureforms / inc Last commit date
abilities 3 weeks ago admin 3 months ago ai-form-builder 1 month ago blocks 2 months ago compatibility 3 weeks ago database 3 weeks ago email 3 weeks ago fields 3 weeks ago global-settings 1 month ago lib 1 month ago migrator 2 months ago page-builders 3 weeks ago payments 3 weeks ago single-form-settings 2 months ago traits 2 months ago activator.php 1 year ago admin-ajax.php 2 months ago background-process.php 9 months ago create-new-form.php 3 months ago duplicate-form.php 3 months ago entries.php 3 weeks ago events-scheduler.php 2 years ago export.php 3 months ago field-validation.php 3 weeks ago form-restriction.php 2 months ago form-styling.php 1 month ago form-submit.php 3 weeks ago forms-data.php 5 months ago frontend-assets.php 1 month ago generate-form-markup.php 3 weeks ago gutenberg-hooks.php 3 weeks ago helper.php 3 weeks ago learn.php 4 months ago onboarding.php 2 months ago post-types.php 1 month ago rest-api.php 3 weeks ago smart-tags.php 4 months ago submit-token.php 4 months ago translatable.php 1 month ago updater-callbacks.php 3 weeks ago updater.php 3 weeks ago
field-validation.php
844 lines
1 <?php
2 /**
3 * Field Validation Class
4 *
5 * Handles all field validation for SureForms
6 *
7 * @package SureForms
8 * @since 1.12.2
9 */
10
11 namespace SRFM\Inc;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit; // Exit if accessed directly.
15 }
16
17 /**
18 * Field Validation Class
19 */
20 class Field_Validation {
21 /**
22 * Add block configuration for form fields.
23 *
24 * This function processes blocks in a form and stores their configuration as post meta.
25 * It applies filters to allow extensions to modify block configs and stores processed
26 * values for blocks that need special handling (like upload fields).
27 *
28 * @param array<mixed> $blocks Array of blocks to process.
29 * @param int $form_id Form post ID.
30 * @return void
31 * @since 1.12.2
32 */
33 public static function add_block_config( $blocks, $form_id ) {
34 // Initialize array to store processed block configurations.
35 $block_config = [];
36
37 // Loop through each block.
38 foreach ( $blocks as $block ) {
39 // Ensure $block is an array and has the required structure.
40 if ( ! is_array( $block ) ) {
41 continue;
42 }
43 if ( ! isset( $block['blockName'] ) || ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
44 continue;
45 }
46 // Validate block id.
47 if ( ! array_key_exists( 'block_id', $block['attrs'] ) || empty( $block['attrs']['block_id'] ) || ! is_string( $block['attrs']['block_id'] ) ) {
48 continue;
49 }
50
51 $block_id = sanitize_text_field( $block['attrs']['block_id'] );
52 $block_name = $block['blockName'];
53
54 // Process specific block types.
55 $processed_config = null;
56
57 switch ( $block_name ) {
58 case 'srfm/payment':
59 $processed_config = self::process_payment_block( $block['attrs'], $blocks );
60 break;
61 case 'srfm/dropdown':
62 $processed_config = self::process_dropdown_block( $block['attrs'] );
63 break;
64 case 'srfm/multi-choice':
65 $processed_config = self::process_multichoice_block( $block['attrs'] );
66 break;
67 case 'srfm/number':
68 $processed_config = self::process_number_block( $block['attrs'] );
69 break;
70 case 'srfm/textarea':
71 $processed_config = self::process_textarea_block( $block['attrs'] );
72 break;
73 }
74
75 // If block was processed, store its configuration.
76 if ( null !== $processed_config && ! empty( $processed_config ) ) {
77 $processed_config['block_name'] = $block_name;
78 // Add the slug to the configuration.
79 if ( isset( $block['attrs']['slug'] ) && ! empty( $block['attrs']['slug'] ) ) {
80 $processed_config['slug'] = sanitize_text_field( $block['attrs']['slug'] );
81 }
82
83 $block_config[ $block_id ] = $processed_config;
84 continue;
85 }
86
87 // Allow extensions to process and modify block config.
88 $config = apply_filters( 'srfm_block_config', [ 'block' => $block ] );
89
90 // If block was processed by a filter, add its processed value.
91 if ( isset( $config['processed_value'] ) && ! empty( $config['processed_value'] ) ) {
92 $block_config[ $block_id ] = $config['processed_value'];
93 continue;
94 }
95 }
96
97 // Sync the meta on every save. When $block_config is empty (e.g. a textarea
98 // whose minLength was cleared, with no other blocks needing per-block
99 // validation), we must clear the stored meta — otherwise the previously
100 // saved values keep being used by the validator.
101 if ( ! empty( $block_config ) ) {
102 update_post_meta( $form_id, '_srfm_block_config', $block_config );
103 } else {
104 delete_post_meta( $form_id, '_srfm_block_config' );
105 }
106 }
107
108 /**
109 * Retrieve or migrate the block configuration for legacy forms.
110 *
111 * This function checks if the _srfm_block_config post meta exists for the given form ID.
112 * Example: get_post_meta( 123, '_srfm_block_config', true ) might return an array of block configs.
113 * If not found, it attempts to parse the form's post content and generate the block config.
114 * Example: If a legacy form with ID 123 has no _srfm_block_config, but its post_content contains blocks,
115 * the function will parse those blocks and call add_block_config() to generate and store the config.
116 *
117 * @param int $form_id The ID of the form post.
118 * @since 1.12.2
119 * @return array|null The block configuration array, or null if not found or invalid.
120 */
121 public static function get_or_migrate_block_config_for_legacy_form( $form_id ) {
122 // Validate that $form_id is a positive integer.
123 // Example: $form_id = 123 is valid; $form_id = -1 or 'abc' is not.
124 if ( ! is_int( $form_id ) || $form_id <= 0 ) {
125 return null;
126 }
127
128 // Retrieve the block config from post meta.
129 // Example: $block_config = [ 'block-1' => [ ... ], 'block-2' => [ ... ] ].
130 $block_config = get_post_meta( $form_id, '_srfm_block_config', true );
131 if ( ! empty( $block_config ) && is_array( $block_config ) ) {
132 // If it exists and is an array, return it directly (no migration needed).
133 // Example: Returning the existing $block_config array.
134 return $block_config;
135 }
136
137 // Get the post by ID and validate.
138 // Example: $post = get_post( 123 ); $post->post_content should contain block markup.
139 $post = get_post( $form_id );
140 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
141 return null;
142 }
143
144 // Parse the blocks from the post content and attempt migration.
145 // Example: $blocks = parse_blocks( $post->post_content ); $blocks is an array of block arrays.
146 if ( function_exists( 'parse_blocks' ) ) {
147 $blocks = parse_blocks( $post->post_content );
148 if ( is_array( $blocks ) && ! empty( $blocks ) ) {
149 self::add_block_config( $blocks, $form_id );
150 }
151 }
152
153 // Retrieve the block config again after migration attempt.
154 // Example: After migration, $block_config should now be an array if successful.
155 $block_config = get_post_meta( $form_id, '_srfm_block_config', true );
156
157 return ! empty( $block_config ) && is_array( $block_config ) ? $block_config : null;
158 }
159
160 /**
161 * Prepare validation data for a given form.
162 *
163 * Retrieves the form block configuration from post meta and adds a 'name_with_id'
164 * key to each block, which is a unique identifier for the field (used for validation).
165 *
166 * @param int $current_form_id The ID of the form post.
167 * @since 1.12.2
168 * @return array|null The processed form configuration array, or null if not found.
169 */
170 public static function prepared_validation_data( $current_form_id ) {
171 // Retrieve the form block configuration from post meta.
172 $get_form_config = self::get_or_migrate_block_config_for_legacy_form( $current_form_id );
173
174 // If the configuration is an array, add a 'name_with_id' key to each block.
175 if ( is_array( $get_form_config ) ) {
176 foreach ( $get_form_config as $index => $block ) {
177 // Ensure both 'blockName' and 'block_id' exist before creating the identifier.
178 if ( isset( $block['blockName'] ) ) {
179 // 'name_with_id' is used as a unique field identifier for validation.
180 // Example: 'sureforms-input-abc123' for blockName 'sureforms/input' and block_id 'abc123'
181 $name_with_id = str_replace( '/', '-', $block['blockName'] ) . '-' . $index;
182
183 // Allow custom filter based on block type.
184 $name_with_id = apply_filters(
185 'srfm_block_config_name_with_id',
186 $name_with_id,
187 $block
188 );
189
190 $get_form_config[ $index ]['name_with_id'] = $name_with_id;
191 }
192 }
193 }
194
195 // Return the processed configuration array, or an empty array if not found.
196 return is_array( $get_form_config ) ? $get_form_config : [];
197 }
198
199 /**
200 * Build the set of block ids that legitimately belong to a form.
201 *
202 * Walks the form's block markup and collects the `block_id` of every SureForms
203 * (`srfm/*`) block, recursing into `innerBlocks` (so repeater/container children are
204 * included) and expanding `core/block` reusable/synced pattern references into their
205 * `wp_block` post (so pattern-embedded fields are included too). A cycle guard on the
206 * reference ids prevents infinite recursion.
207 *
208 * Used by {@see self::strip_unknown_field_keys()}, which DROPS submitted keys whose
209 * block id is not part of the form rather than rejecting the submission — see the
210 * note there for why rejecting made cached forms unsubmittable.
211 *
212 * Note: this is deliberately NOT `prepared_validation_data()`
213 * — that map only holds blocks with extra validation config (dropdowns, payments,
214 * textarea min-length) and omits plain inputs, so it is not a field allowlist.
215 *
216 * @param int|mixed $form_id The form post id.
217 * @since 2.12.3
218 * @return array<string,true> Map of known block id => true. Empty when the form's
219 * blocks could not be derived (callers should fail open).
220 */
221 public static function get_known_field_block_ids( $form_id ) {
222 static $cache = [];
223
224 $form_id = Helper::get_integer_value( $form_id );
225 if ( $form_id <= 0 ) {
226 return [];
227 }
228
229 // Only the block walk is memoised. The filtered result deliberately is not: a
230 // third party returning a malformed value would otherwise poison the set for the
231 // rest of the request, and because the lookup short-circuits on isset() the walk
232 // would never be retried.
233 if ( ! isset( $cache[ $form_id ] ) ) {
234 $ids = [];
235 $post = get_post( $form_id );
236
237 if ( $post instanceof \WP_Post && ! empty( $post->post_content ) && function_exists( 'parse_blocks' ) ) {
238 $visited = [];
239 self::collect_field_block_ids( parse_blocks( $post->post_content ), $ids, $visited );
240 }
241
242 $cache[ $form_id ] = $ids;
243 }
244
245 /**
246 * Filter the set of block ids considered valid for a form during submission.
247 *
248 * Extensions that inject legitimate fields not present in the form's own block
249 * markup (for example dynamically generated keys) can add their block ids here so
250 * those submissions are not dropped as unknown.
251 *
252 * Expects a map of `block_id => true`. A plain list of ids is accepted and
253 * converted; any other return value is ignored in favour of the walked set.
254 *
255 * @since 2.12.3
256 * @param array<string,true> $ids Map of known block id => true.
257 * @param int $form_id The form post id.
258 */
259 $ids = apply_filters( 'srfm_known_field_block_ids', $cache[ $form_id ], $form_id );
260
261 // Coerce defensively. A callback returning a list (`[ 'aaa', 'bbb' ]`) rather
262 // than a map would otherwise make every real field look unknown, and a non-array
263 // return would drop the whole allowlist — so fall back to the walked set instead
264 // of silently turning the check off.
265 if ( ! is_array( $ids ) ) {
266 return $cache[ $form_id ];
267 }
268
269 return wp_is_numeric_array( $ids ) ? array_fill_keys( array_map( 'strval', $ids ), true ) : $ids;
270 }
271
272 /**
273 * Remove submitted field keys the form does not define.
274 *
275 * SECURITY INVARIANT — every submitted key must be checked against the form's own
276 * definition. The `-lbl-` substring proves only that a key LOOKS like a SureForms
277 * field, not that this form actually defines it, so shape alone is never sufficient:
278 * only keys the form declares may reach storage, email or export.
279 *
280 * Unknown keys are dropped rather than rejected. Rejecting looked safer but behaved
281 * badly: the allowlist is derived from `post_content` at submit time while the
282 * visitor's HTML was rendered earlier, so full-page caching or an editor-side
283 * `block_id` reassignment would make an otherwise valid form unsubmittable behind an
284 * error the visitor cannot act on. Dropping meets the same security goal — the
285 * invented key never reaches storage, email or export — without that failure mode.
286 *
287 * Repeater rows arrive as `repeaterKey[index][childKey]`, which PHP collapses into a
288 * single top-level key holding nested arrays. Those child keys are copied verbatim by
289 * Pro's `process_repeater_field()` and label-decoded downstream, so they are walked
290 * here too; the allowlist already contains repeater children because the collector
291 * recurses into `innerBlocks`.
292 *
293 * @param array<mixed> $form_data The submitted form data (sanitized).
294 * @param int|mixed $form_id The ID of the form being submitted.
295 * @since 2.12.3
296 * @return array<mixed> The form data with unknown field keys removed.
297 */
298 public static function strip_unknown_field_keys( $form_data, $form_id ) {
299 if ( ! is_array( $form_data ) ) {
300 return [];
301 }
302
303 $known_block_ids = self::get_known_field_block_ids( Helper::get_integer_value( $form_id ) );
304
305 // Fail open. The set is empty only when the form's blocks could not be derived
306 // (no/empty post_content, a parse failure, or a structure this walk does not
307 // recognise). Enforcing on an empty set would strip every field.
308 if ( empty( $known_block_ids ) ) {
309 return $form_data;
310 }
311
312 foreach ( $form_data as $key => $value ) {
313 if ( ! is_string( $key ) || false === strpos( $key, '-lbl-' ) ) {
314 continue;
315 }
316
317 if ( ! isset( $known_block_ids[ Helper::get_block_id_from_key( $key ) ] ) ) {
318 unset( $form_data[ $key ] );
319 continue;
320 }
321
322 // A known key whose value is an array is a repeater: walk its rows.
323 if ( is_array( $value ) ) {
324 $form_data[ $key ] = self::strip_unknown_repeater_keys( $value, $known_block_ids );
325 }
326 }
327
328 return $form_data;
329 }
330
331 /**
332 * Validate form data for a given form.
333 *
334 * This function checks each field in the submitted form data (including uploaded files)
335 * and applies the 'srfm_validate_form_data' filter to validate each field according to
336 * its configuration. Only fields with keys containing '-lbl-' (SureForms fields) are processed.
337 * If a field fails validation, its error message is added to the $not_valid_fields array.
338 *
339 * @param array<mixed> $form_data The submitted form data (sanitized).
340 * @param int|mixed $current_form_id The ID of the form being validated.
341 * @since 1.12.2
342 * @return array An array of invalid fields and their error messages. Empty if all fields are valid.
343 */
344 public static function validate_form_data( $form_data, $current_form_id ) {
345 if ( ! is_array( $form_data ) || ! is_numeric( $current_form_id ) ) {
346 return [];
347 }
348
349 // Holds fields that are not valid. Example: [ 'srfm-email-c867d9d9-lbl-email' => 'This field is required.' ].
350 $not_valid_fields = [];
351
352 // Retrieve the processed form configuration for validation.
353 $get_form_config = self::prepared_validation_data( Helper::get_integer_value( $current_form_id ) );
354
355 $form_data = apply_filters( 'srfm_field_validation_data', $form_data );
356
357 // Iterate over each field in the form data.
358 foreach ( $form_data as $key => $value ) {
359 /**
360 * Only process SureForms fields.
361 * The '-lbl-' substring is mandatory in SureForms field keys.
362 * Example: $key = 'srfm-email-c867d9d9-lbl-email'
363 */
364 if ( false === strpos( $key, '-lbl-' ) ) {
365 continue;
366 }
367
368 $get_name_with_id = explode( '-lbl-', $key );
369 // Extract the block id, i.e. the segment right before the first '-lbl-'.
370 // Example: $get_name_with_id[0] = "srfm-email-c867d9d9" => "c867d9d9".
371 //
372 // Uses the shared Helper so submission agrees with every downstream consumer
373 // of a field key (entries export, uniqueness check, smart tags). A local
374 // regex here would define a second, subtly different notion of "the block
375 // id" and the two could disagree on a legacy id.
376 $extracted_id = is_string( $key ) ? Helper::get_block_id_from_key( $key ) : '';
377
378 // $get_slug will be the slug after the first hyphen in the second part.
379 // Example: $get_name_with_id[1] = "email" or "field-email", $get_slug = "email".
380 $get_slug = isset( $get_name_with_id[1] ) ? preg_replace( '/^[^-]+-/', '', $get_name_with_id[1] ) : '';
381
382 // $get_field_name is the field name without the block id.
383 // Example: "srfm-email-c867d9d9" => "srfm-email".
384 $get_field_name = str_replace( '-' . $extracted_id, '', $get_name_with_id[0] );
385
386 // Apply the validation filter for the current field.
387 // Example: Passes all relevant field data to the filter for validation.
388 $field_validated = apply_filters(
389 'srfm_validate_form_data',
390 [
391 'field_key' => $key,
392 'field_value' => $value,
393 'form_id' => $current_form_id,
394 'form_config' => $get_form_config,
395 'block_id' => $extracted_id,
396 'block_slug' => $get_slug,
397 'name_with_id' => $get_name_with_id[0],
398 'field_name' => $get_field_name,
399 ]
400 );
401
402 // Check the result of the validation.
403 // Example: $field_validated = [ 'validated' => false, 'error' => 'This field is required.' ].
404 if ( isset( $field_validated['validated'] ) ) {
405 // If the field is valid, skip to the next field.
406 if ( true === $field_validated['validated'] ) {
407 continue;
408 }
409
410 // If the field is not valid, add the error message to the result array.
411 // Example: $not_valid_fields[ 'srfm-email-c867d9d9-lbl-email' ] = 'This field is required.'.
412 if ( false === $field_validated['validated'] ) {
413 $not_valid_fields[ $key ] = $field_validated['error'] ?? __( 'Field is not valid.', 'sureforms' );
414 }
415 }
416
417 // Textarea minimum character server-side validation.
418 if ( 'srfm-textarea' === $get_field_name && is_string( $value ) && '' !== $value ) {
419 $block_config = isset( $get_form_config[ $extracted_id ] ) && is_array( $get_form_config[ $extracted_id ] ) ? $get_form_config[ $extracted_id ] : [];
420 $min_length = isset( $block_config['min_length'] ) ? absint( $block_config['min_length'] ) : 0;
421 if ( $min_length > 0 && mb_strlen( $value ) < $min_length ) {
422 $dynamic_messages = Translatable::dynamic_validation_messages();
423 $min_chars_message = isset( $dynamic_messages['srfm_textarea_min_chars'] ) && is_string( $dynamic_messages['srfm_textarea_min_chars'] ) && '' !== $dynamic_messages['srfm_textarea_min_chars']
424 ? $dynamic_messages['srfm_textarea_min_chars']
425 /* translators: %s represents the minimum number of characters required */
426 : __( 'Please enter at least %s characters.', 'sureforms' );
427 $not_valid_fields[ $key ] = sprintf( $min_chars_message, $min_length );
428 }
429 }
430
431 // Email field RFC 5321 length limits (local part / domain), overridable via filter.
432 // Only the main email value is in form data (the confirm input has no `name`),
433 // so the server validates that value; the client mirrors this for both inputs.
434 // Split on the LAST @ per RFC 5321 so the local part may contain a quoted @.
435 $at_pos = is_string( $value ) && '' !== $value ? strrpos( $value, '@' ) : false;
436 if ( 'srfm-email' === $get_field_name && is_string( $value ) && false !== $at_pos ) {
437 $email_limits = self::get_email_char_limits();
438 $local_max = $email_limits['local'];
439 $domain_max = $email_limits['domain'];
440 $local_len = mb_strlen( substr( $value, 0, $at_pos ) );
441 $domain_len = mb_strlen( substr( $value, $at_pos + 1 ) );
442
443 $dynamic_messages = Translatable::dynamic_validation_messages();
444 if ( $local_max > 0 && $local_len > $local_max ) {
445 $local_message = isset( $dynamic_messages['srfm_email_local_max_length'] ) && is_string( $dynamic_messages['srfm_email_local_max_length'] ) && '' !== $dynamic_messages['srfm_email_local_max_length']
446 ? $dynamic_messages['srfm_email_local_max_length']
447 /* translators: %s: maximum characters allowed before the @ symbol. */
448 : __( 'The part before @ may not exceed %s characters.', 'sureforms' );
449 $not_valid_fields[ $key ] = sprintf( $local_message, $local_max );
450 } elseif ( $domain_max > 0 && $domain_len > $domain_max ) {
451 $domain_message = isset( $dynamic_messages['srfm_email_domain_max_length'] ) && is_string( $dynamic_messages['srfm_email_domain_max_length'] ) && '' !== $dynamic_messages['srfm_email_domain_max_length']
452 ? $dynamic_messages['srfm_email_domain_max_length']
453 /* translators: %s: maximum characters allowed after the @ symbol. */
454 : __( 'The part after @ may not exceed %s characters.', 'sureforms' );
455 $not_valid_fields[ $key ] = sprintf( $domain_message, $domain_max );
456 }
457 }
458 }
459
460 // Return the array of invalid fields and their error messages.
461 // Example: [ 'srfm-email-c867d9d9-lbl-email' => 'This field is required.' ].
462 return $not_valid_fields;
463 }
464
465 /**
466 * Resolve the Email field character limits (RFC 5321), split on the last @.
467 *
468 * Single source of truth shared by the server validation and the limits localized to the
469 * frontend script, so a filter override applies consistently to both.
470 *
471 * @return array{local:int,domain:int} Resolved limits. A value of 0 disables that check.
472 * @since 2.12.1
473 */
474 public static function get_email_char_limits() {
475 /**
476 * Filters the Email field character limits (RFC 5321).
477 *
478 * @param array $limits {
479 * Character limits for the email value, split on the last @.
480 *
481 * @type int $local Max characters before the @. 0 disables the check. Default 64.
482 * @type int $domain Max characters after the @. 0 disables the check. Default 255.
483 * }
484 * @since 2.12.1
485 */
486 $email_limits = apply_filters(
487 'srfm_email_field_char_limits',
488 [
489 'local' => 64,
490 'domain' => 255,
491 ]
492 );
493
494 return [
495 'local' => isset( $email_limits['local'] ) ? absint( $email_limits['local'] ) : 64,
496 'domain' => isset( $email_limits['domain'] ) ? absint( $email_limits['domain'] ) : 255,
497 ];
498 }
499
500 /**
501 * Remove unknown child field keys from repeater rows.
502 *
503 * @param array<mixed> $rows The repeater's submitted rows.
504 * @param array<string,true> $known_block_ids Map of block ids belonging to the form.
505 * @since 2.12.3
506 * @return array<mixed> The rows with unknown child keys removed.
507 */
508 private static function strip_unknown_repeater_keys( $rows, $known_block_ids ) {
509 foreach ( $rows as $index => $row ) {
510 if ( ! is_array( $row ) ) {
511 continue;
512 }
513
514 foreach ( array_keys( $row ) as $child_key ) {
515 if ( ! is_string( $child_key ) || false === strpos( $child_key, '-lbl-' ) ) {
516 continue;
517 }
518
519 if ( ! isset( $known_block_ids[ Helper::get_block_id_from_key( $child_key ) ] ) ) {
520 unset( $row[ $child_key ] );
521 }
522 }
523
524 $rows[ $index ] = $row;
525 }
526
527 return $rows;
528 }
529
530 /**
531 * Recursively collect SureForms block ids from a parsed block tree.
532 *
533 * @param array<mixed> $blocks Parsed blocks from parse_blocks().
534 * @param array<string,true> $ids Accumulator of block id => true (by reference).
535 * @param array<int,true> $visited Expanded reusable-block post ids, guards cycles.
536 * @param int $depth Current recursion depth, guards pathological trees.
537 * @since 2.12.3
538 * @return void
539 */
540 private static function collect_field_block_ids( $blocks, &$ids, &$visited, $depth = 0 ) {
541 if ( ! is_array( $blocks ) || $depth > 50 ) {
542 return;
543 }
544
545 foreach ( $blocks as $block ) {
546 if ( ! is_array( $block ) ) {
547 continue;
548 }
549
550 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
551 $block_name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
552
553 // Stored raw, deliberately. The lookup side derives the id from the submitted
554 // key via Helper::get_block_id_from_key(), which does not sanitise — putting
555 // sanitize_text_field() only on this side would file any id the sanitiser
556 // alters under a different string than the one looked up, making a legitimate
557 // field permanently unsubmittable. These are map keys used for comparison
558 // only; nothing is echoed from here.
559 if ( 0 === strpos( $block_name, 'srfm/' ) && ! empty( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ) {
560 $ids[ $attrs['block_id'] ] = true;
561 }
562
563 // Expand reusable/synced patterns so fields living inside a pattern count as
564 // part of the form.
565 if ( 'core/block' === $block_name && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
566 $ref = absint( $attrs['ref'] );
567 if ( $ref > 0 && ! isset( $visited[ $ref ] ) ) {
568 $visited[ $ref ] = true;
569 $ref_post = get_post( $ref );
570 if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) {
571 self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $visited, $depth + 1 );
572 }
573 }
574 }
575
576 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
577 self::collect_field_block_ids( $block['innerBlocks'], $ids, $visited, $depth + 1 );
578 }
579 }
580 }
581
582 /**
583 * Process payment block configuration.
584 *
585 * @param array<mixed> $attrs Block attributes.
586 * @param array<mixed> $blocks All blocks.
587 * @return array Processed payment configuration.
588 * @since 2.3.0
589 */
590 private static function process_payment_block( $attrs, $blocks ) {
591 $payment_config = [];
592
593 // Extract payment type (single or subscription).
594 $payment_config['payment_type'] = isset( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] ) ? sanitize_text_field( $attrs['paymentType'] ) : 'one-time';
595
596 // Persist subscription plan (interval + billing cycles) for any form that
597 // has a subscription path. The admin picks a single value for each in the
598 // editor; the server uses these stored values as the source of truth on
599 // submit so a tampered interval/cycles in form data cannot redirect Stripe
600 // to a different billing cadence.
601 if ( in_array( $payment_config['payment_type'], [ 'subscription', 'both' ], true ) && isset( $attrs['subscriptionPlan'] ) && is_array( $attrs['subscriptionPlan'] ) ) {
602 if ( isset( $attrs['subscriptionPlan']['interval'] ) && is_string( $attrs['subscriptionPlan']['interval'] ) ) {
603 $payment_config['subscription_interval'] = sanitize_text_field( $attrs['subscriptionPlan']['interval'] );
604 }
605 if ( isset( $attrs['subscriptionPlan']['billingCycles'] ) ) {
606 // billingCycles is either an integer count or the string 'ongoing'.
607 $cycles_raw = $attrs['subscriptionPlan']['billingCycles'];
608 $payment_config['subscription_billing_cycles'] = is_numeric( $cycles_raw ) ? intval( $cycles_raw ) : sanitize_text_field( (string) $cycles_raw );
609 }
610 }
611
612 // Extract amount type (fixed or minimum).
613 $payment_config['amount_type'] = isset( $attrs['amountType'] ) && is_string( $attrs['amountType'] ) ? sanitize_text_field( $attrs['amountType'] ) : 'fixed';
614
615 $payment_config['fixed_amount'] = isset( $attrs['fixedAmount'] ) ? floatval( $attrs['fixedAmount'] ) : 10;
616
617 $payment_config['minimum_amount'] = isset( $attrs['minimumAmount'] ) ? floatval( $attrs['minimumAmount'] ) : 0;
618
619 // Extract variable amount field reference.
620 if ( isset( $attrs['variableAmountField'] ) ) {
621 $variable_amount_slug = sanitize_text_field( $attrs['variableAmountField'] );
622 $payment_config['variable_amount_field'] = $variable_amount_slug;
623
624 // Find and add the block name from which the variable amount field comes from.
625 if ( ! empty( $variable_amount_slug ) && is_array( $blocks ) ) {
626 foreach ( $blocks as $block ) {
627 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $variable_amount_slug ) {
628 $payment_config['variable_amount_field_block_name'] = $block['blockName'];
629 break;
630 }
631 }
632 }
633 }
634
635 // BOTH MODE: store per-type amount configs so server-side validation can
636 // use the correct config based on which flow the user actually chose.
637 if ( 'both' === $payment_config['payment_type'] ) {
638 $payment_config['one_time_amount_type'] = isset( $attrs['oneTimeAmountType'] ) && is_string( $attrs['oneTimeAmountType'] ) ? sanitize_text_field( $attrs['oneTimeAmountType'] ) : 'fixed';
639 $payment_config['one_time_fixed_amount'] = isset( $attrs['oneTimeFixedAmount'] ) ? floatval( $attrs['oneTimeFixedAmount'] ) : 10;
640 $payment_config['one_time_minimum_amount'] = isset( $attrs['oneTimeMinimumAmount'] ) ? floatval( $attrs['oneTimeMinimumAmount'] ) : 0;
641
642 if ( isset( $attrs['oneTimeVariableAmountField'] ) ) {
643 $ot_slug = sanitize_text_field( $attrs['oneTimeVariableAmountField'] );
644 $payment_config['one_time_variable_amount_field'] = $ot_slug;
645 if ( ! empty( $ot_slug ) && is_array( $blocks ) ) {
646 foreach ( $blocks as $block ) {
647 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $ot_slug ) {
648 $payment_config['one_time_variable_amount_field_block_name'] = $block['blockName'];
649 break;
650 }
651 }
652 }
653 }
654
655 $payment_config['subscription_amount_type'] = isset( $attrs['subscriptionAmountType'] ) && is_string( $attrs['subscriptionAmountType'] ) ? sanitize_text_field( $attrs['subscriptionAmountType'] ) : 'fixed';
656 $payment_config['subscription_fixed_amount'] = isset( $attrs['subscriptionFixedAmount'] ) ? floatval( $attrs['subscriptionFixedAmount'] ) : 10;
657 $payment_config['subscription_minimum_amount'] = isset( $attrs['subscriptionMinimumAmount'] ) ? floatval( $attrs['subscriptionMinimumAmount'] ) : 0;
658
659 if ( isset( $attrs['subscriptionVariableAmountField'] ) ) {
660 $sub_slug = sanitize_text_field( $attrs['subscriptionVariableAmountField'] );
661 $payment_config['subscription_variable_amount_field'] = $sub_slug;
662 if ( ! empty( $sub_slug ) && is_array( $blocks ) ) {
663 foreach ( $blocks as $block ) {
664 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $sub_slug ) {
665 $payment_config['subscription_variable_amount_field_block_name'] = $block['blockName'];
666 break;
667 }
668 }
669 }
670 }
671 }
672
673 return $payment_config;
674 }
675
676 /**
677 * Process dropdown block configuration.
678 *
679 * @param array<mixed> $attrs Block attributes.
680 * @return array Processed dropdown configuration.
681 * @since 2.3.0
682 */
683 private static function process_dropdown_block( $attrs ) {
684 $dropdown_config = [];
685
686 // Extract required field.
687 $dropdown_config['required'] = isset( $attrs['required'] ) && ! empty( $attrs['required'] ) ? true : false;
688
689 // Extract options with their full structure (label, icon, value).
690 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
691 $sanitized_options = [];
692 foreach ( $attrs['options'] as $option ) {
693 if ( is_array( $option ) ) {
694 $sanitized_options[] = [
695 'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
696 'icon' => isset( $option['icon'] ) ? sanitize_text_field( $option['icon'] ) : '',
697 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
698 ];
699 }
700 }
701 $dropdown_config['options'] = $sanitized_options;
702 }
703
704 // Extract showValues flag.
705 $dropdown_config['show_values'] = isset( $attrs['showValues'] ) ? rest_sanitize_boolean( $attrs['showValues'] ) : false;
706
707 // Extract multiSelect flag.
708 if ( isset( $attrs['multiSelect'] ) ) {
709 $dropdown_config['multi_select'] = rest_sanitize_boolean( $attrs['multiSelect'] );
710 }
711
712 // Extract minValue for multi-select validation.
713 if ( isset( $attrs['minValue'] ) ) {
714 $dropdown_config['min_value'] = absint( $attrs['minValue'] );
715 }
716
717 // Extract maxValue for multi-select validation.
718 if ( isset( $attrs['maxValue'] ) ) {
719 $dropdown_config['max_value'] = absint( $attrs['maxValue'] );
720 }
721
722 return $dropdown_config;
723 }
724
725 /**
726 * Process multi-choice block configuration.
727 *
728 * @param array<mixed> $attrs Block attributes.
729 * @return array Processed multi-choice configuration.
730 * @since 2.3.0
731 */
732 private static function process_multichoice_block( $attrs ) {
733 $multichoice_config = [];
734
735 // Extract required field.
736 $multichoice_config['required'] = isset( $attrs['required'] ) && ! empty( $attrs['required'] ) ? true : false;
737
738 // Extract singleSelection flag.
739 if ( isset( $attrs['singleSelection'] ) ) {
740 $multichoice_config['single_selection'] = rest_sanitize_boolean( $attrs['singleSelection'] );
741 }
742
743 // Extract minValue for validation.
744 if ( isset( $attrs['minValue'] ) ) {
745 $multichoice_config['min_value'] = absint( $attrs['minValue'] );
746 }
747
748 // Extract maxValue for validation.
749 if ( isset( $attrs['maxValue'] ) ) {
750 $multichoice_config['max_value'] = absint( $attrs['maxValue'] );
751 }
752
753 // Extract options with their full structure (label, icon, value).
754 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
755 $sanitized_options = [];
756 foreach ( $attrs['options'] as $option ) {
757 if ( is_array( $option ) ) {
758 $sanitized_options[] = [
759 'label' => isset( $option['optionTitle'] ) ? trim( sanitize_text_field( $option['optionTitle'] ) ) : '',
760 'icon' => isset( $option['icon'] ) ? sanitize_text_field( $option['icon'] ) : '',
761 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
762 ];
763 }
764 }
765 $multichoice_config['options'] = $sanitized_options;
766 }
767
768 // Extract showValues flag.
769 if ( isset( $attrs['showValues'] ) ) {
770 $multichoice_config['show_values'] = rest_sanitize_boolean( $attrs['showValues'] );
771 }
772
773 return $multichoice_config;
774 }
775
776 /**
777 * Process textarea block configuration.
778 *
779 * @param array<mixed> $attrs Block attributes.
780 * @return array Processed textarea configuration.
781 * @since 2.8.2
782 */
783 private static function process_textarea_block( $attrs ) {
784 // Always emit a min_length key so a cleared/invalid value overwrites any
785 // previously stored config on save instead of falling back to stale data.
786 // Rich-text editors submit HTML markup which would skew mb_strlen counts,
787 // so they're treated as "no min-length validation".
788 if ( ! empty( $attrs['isRichText'] ) ) {
789 return [ 'min_length' => 0 ];
790 }
791
792 $min_length = isset( $attrs['minLength'] ) && is_numeric( $attrs['minLength'] ) ? absint( $attrs['minLength'] ) : 0;
793 $max_length = isset( $attrs['maxLength'] ) && is_numeric( $attrs['maxLength'] ) ? absint( $attrs['maxLength'] ) : 0;
794
795 // Misconfiguration guard — drop min when it exceeds max so the form stays submittable.
796 if ( $max_length > 0 && $min_length > $max_length ) {
797 $min_length = 0;
798 }
799
800 return [ 'min_length' => $min_length ];
801 }
802
803 /**
804 * Process number block configuration.
805 *
806 * @param array<mixed> $attrs Block attributes.
807 * @return array Processed number configuration.
808 * @since 2.4.0
809 */
810 private static function process_number_block( $attrs ) {
811 $number_config = [];
812
813 // Extract required field.
814 if ( isset( $attrs['required'] ) ) {
815 $number_config['required'] = ! empty( $attrs['required'] ) ? true : false;
816 }
817
818 // Extract format type (us-style or eu-style).
819 $number_config['format_type'] = isset( $attrs['formatType'] ) && is_string( $attrs['formatType'] ) ? sanitize_text_field( $attrs['formatType'] ) : 'us-style';
820
821 // Extract min value.
822 if ( isset( $attrs['min'] ) ) {
823 $number_config['min'] = floatval( $attrs['min'] );
824 }
825
826 // Extract max value.
827 if ( isset( $attrs['max'] ) ) {
828 $number_config['max'] = floatval( $attrs['max'] );
829 }
830
831 // Capture calculation config (Pro feature) so a calculation-driven number field used as a
832 // payment amount source can be re-derived server-side instead of trusting the submitted
833 // value. Harmless when the calculation feature is not in use.
834 if ( ! empty( $attrs['enableCalculation'] ) ) {
835 $number_config['enableCalculation'] = true;
836 $number_config['calculationFormula'] = isset( $attrs['calculationFormula'] ) && is_string( $attrs['calculationFormula'] ) ? $attrs['calculationFormula'] : '';
837 // Stored as null when unset so the validator rounds only when a precision is configured.
838 $number_config['calculationRound'] = isset( $attrs['calculationRound'] ) && is_numeric( $attrs['calculationRound'] ) ? absint( $attrs['calculationRound'] ) : null;
839 }
840
841 return $number_config;
842 }
843 }
844