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

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

975 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 $form_id = Helper::get_integer_value( $form_id );
223 if ( $form_id <= 0 ) {
224 return [];
225 }
226
227 $walked = self::get_field_identifiers( $form_id );
228
229 /**
230 * Filter the set of block ids considered valid for a form during submission.
231 *
232 * Extensions that inject legitimate fields not present in the form's own block
233 * markup (for example dynamically generated keys) can add their block ids here so
234 * those submissions are not dropped as unknown.
235 *
236 * Expects a map of `block_id => true`. A plain list of ids is accepted and
237 * converted; any other return value is ignored in favour of the walked set.
238 *
239 * @since 2.12.3
240 * @param array<string,true> $ids Map of known block id => true.
241 * @param int $form_id The form post id.
242 */
243 $ids = apply_filters( 'srfm_known_field_block_ids', $walked['ids'], $form_id );
244
245 // Coerce defensively. A callback returning a list (`[ 'aaa', 'bbb' ]`) rather
246 // than a map would otherwise make every real field look unknown, and a non-array
247 // return would drop the whole allowlist — so fall back to the walked set instead
248 // of silently turning the check off.
249 if ( ! is_array( $ids ) ) {
250 return $walked['ids'];
251 }
252
253 return wp_is_numeric_array( $ids ) ? array_fill_keys( array_map( 'strval', $ids ), true ) : $ids;
254 }
255
256 /**
257 * The slugs of every SureForms field the form defines, block_id => true style.
258 *
259 * A slug is stable across renders where a block_id is not, so it is the identifier
260 * used to keep a submitted field whose block_id drifted (full-page cache, an editor
261 * rebuild). Mirrors get_known_field_block_ids(): one memoised walk, an unmemoised
262 * filter pass.
263 *
264 * @param int|mixed $form_id The form post id.
265 * @since 2.12.7
266 * @return array<string,true> Map of known slug => true.
267 */
268 public static function get_known_field_slugs( $form_id ) {
269 $form_id = Helper::get_integer_value( $form_id );
270 if ( $form_id <= 0 ) {
271 return [];
272 }
273
274 $walked = self::get_field_identifiers( $form_id );
275
276 /**
277 * Filter the set of field slugs considered valid for a form during submission.
278 *
279 * @since 2.12.7
280 * @param array<string,true> $slugs Map of known slug => true.
281 * @param int $form_id The form post id.
282 */
283 $slugs = apply_filters( 'srfm_known_field_slugs', $walked['slugs'], $form_id );
284
285 if ( ! is_array( $slugs ) ) {
286 return $walked['slugs'];
287 }
288
289 return wp_is_numeric_array( $slugs ) ? array_fill_keys( array_map( 'strval', $slugs ), true ) : $slugs;
290 }
291
292 /**
293 * Remove submitted field keys the form does not define.
294 *
295 * SECURITY INVARIANT — every submitted key must be checked against the form's own
296 * definition. The `-lbl-` substring proves only that a key LOOKS like a SureForms
297 * field, not that this form actually defines it, so shape alone is never sufficient:
298 * only keys the form declares may reach storage, email or export.
299 *
300 * Unknown keys are dropped rather than rejected. Rejecting looked safer but behaved
301 * badly: the allowlist is derived from `post_content` at submit time while the
302 * visitor's HTML was rendered earlier, so full-page caching or an editor-side
303 * `block_id` reassignment would make an otherwise valid form unsubmittable behind an
304 * error the visitor cannot act on. Dropping meets the same security goal — the
305 * invented key never reaches storage, email or export — without that failure mode.
306 *
307 * Repeater rows arrive as `repeaterKey[index][childKey]`, which PHP collapses into a
308 * single top-level key holding nested arrays. Those child keys are copied verbatim by
309 * Pro's `process_repeater_field()` and label-decoded downstream, so they are walked
310 * here too; the allowlist already contains repeater children because the collector
311 * recurses into `innerBlocks`.
312 *
313 * @param array<mixed> $form_data The submitted form data (sanitized).
314 * @param int|mixed $form_id The ID of the form being submitted.
315 * @since 2.12.3
316 * @return array<mixed> The form data with unknown field keys removed.
317 */
318 public static function strip_unknown_field_keys( $form_data, $form_id ) {
319 if ( ! is_array( $form_data ) ) {
320 return [];
321 }
322
323 $form_id_int = Helper::get_integer_value( $form_id );
324 $known_block_ids = self::get_known_field_block_ids( $form_id_int );
325 $known_slugs = self::get_known_field_slugs( $form_id_int );
326
327 // Fail open. The sets are empty only when the form's blocks could not be derived
328 // (no/empty post_content, a parse failure, or a structure this walk does not
329 // recognise). Enforcing on an empty set would strip every field.
330 if ( empty( $known_block_ids ) && empty( $known_slugs ) ) {
331 return $form_data;
332 }
333
334 foreach ( $form_data as $key => $value ) {
335 if ( ! is_string( $key ) || false === strpos( $key, '-lbl-' ) ) {
336 continue;
337 }
338
339 // Keep the field when EITHER its block_id OR its slug matches the form. The
340 // block_id can drift between the (possibly full-page-cached) HTML the visitor
341 // submitted and the current post_content; the slug does not, so requiring only
342 // a block_id match silently deleted real submissions (#1517643). A key with
343 // neither a known block_id nor a known slug is genuinely foreign and dropped.
344 if ( ! self::field_key_belongs_to_form( $key, $known_block_ids, $known_slugs ) ) {
345 unset( $form_data[ $key ] );
346 continue;
347 }
348
349 // A known key whose value is an array is a repeater: walk its rows.
350 if ( is_array( $value ) ) {
351 $form_data[ $key ] = self::strip_unknown_repeater_keys( $value, $known_block_ids, $known_slugs );
352 }
353 }
354
355 return $form_data;
356 }
357
358 /**
359 * Validate form data for a given form.
360 *
361 * This function checks each field in the submitted form data (including uploaded files)
362 * and applies the 'srfm_validate_form_data' filter to validate each field according to
363 * its configuration. Only fields with keys containing '-lbl-' (SureForms fields) are processed.
364 * If a field fails validation, its error message is added to the $not_valid_fields array.
365 *
366 * @param array<mixed> $form_data The submitted form data (sanitized).
367 * @param int|mixed $current_form_id The ID of the form being validated.
368 * @since 1.12.2
369 * @return array An array of invalid fields and their error messages. Empty if all fields are valid.
370 */
371 public static function validate_form_data( $form_data, $current_form_id ) {
372 if ( ! is_array( $form_data ) || ! is_numeric( $current_form_id ) ) {
373 return [];
374 }
375
376 // Holds fields that are not valid. Example: [ 'srfm-email-c867d9d9-lbl-email' => 'This field is required.' ].
377 $not_valid_fields = [];
378
379 // Retrieve the processed form configuration for validation.
380 $get_form_config = self::prepared_validation_data( Helper::get_integer_value( $current_form_id ) );
381
382 $form_data = apply_filters( 'srfm_field_validation_data', $form_data );
383
384 // Iterate over each field in the form data.
385 foreach ( $form_data as $key => $value ) {
386 /**
387 * Only process SureForms fields.
388 * The '-lbl-' substring is mandatory in SureForms field keys.
389 * Example: $key = 'srfm-email-c867d9d9-lbl-email'
390 */
391 if ( false === strpos( $key, '-lbl-' ) ) {
392 continue;
393 }
394
395 $get_name_with_id = explode( '-lbl-', $key );
396 // Extract the block id, i.e. the segment right before the first '-lbl-'.
397 // Example: $get_name_with_id[0] = "srfm-email-c867d9d9" => "c867d9d9".
398 //
399 // Uses the shared Helper so submission agrees with every downstream consumer
400 // of a field key (entries export, uniqueness check, smart tags). A local
401 // regex here would define a second, subtly different notion of "the block
402 // id" and the two could disagree on a legacy id.
403 $extracted_id = is_string( $key ) ? Helper::get_block_id_from_key( $key ) : '';
404
405 // $get_slug will be the slug after the first hyphen in the second part.
406 // Example: $get_name_with_id[1] = "email" or "field-email", $get_slug = "email".
407 $get_slug = isset( $get_name_with_id[1] ) ? preg_replace( '/^[^-]+-/', '', $get_name_with_id[1] ) : '';
408
409 // $get_field_name is the field name without the block id.
410 // Example: "srfm-email-c867d9d9" => "srfm-email".
411 $get_field_name = str_replace( '-' . $extracted_id, '', $get_name_with_id[0] );
412
413 // Apply the validation filter for the current field.
414 // Example: Passes all relevant field data to the filter for validation.
415 $field_validated = apply_filters(
416 'srfm_validate_form_data',
417 [
418 'field_key' => $key,
419 'field_value' => $value,
420 'form_id' => $current_form_id,
421 'form_config' => $get_form_config,
422 'block_id' => $extracted_id,
423 'block_slug' => $get_slug,
424 'name_with_id' => $get_name_with_id[0],
425 'field_name' => $get_field_name,
426 ]
427 );
428
429 // Check the result of the validation.
430 // Example: $field_validated = [ 'validated' => false, 'error' => 'This field is required.' ].
431 if ( isset( $field_validated['validated'] ) ) {
432 // If the field is valid, skip to the next field.
433 if ( true === $field_validated['validated'] ) {
434 continue;
435 }
436
437 // If the field is not valid, add the error message to the result array.
438 // Example: $not_valid_fields[ 'srfm-email-c867d9d9-lbl-email' ] = 'This field is required.'.
439 if ( false === $field_validated['validated'] ) {
440 $not_valid_fields[ $key ] = $field_validated['error'] ?? __( 'Field is not valid.', 'sureforms' );
441 }
442 }
443
444 // Textarea minimum character server-side validation.
445 if ( 'srfm-textarea' === $get_field_name && is_string( $value ) && '' !== $value ) {
446 $block_config = isset( $get_form_config[ $extracted_id ] ) && is_array( $get_form_config[ $extracted_id ] ) ? $get_form_config[ $extracted_id ] : [];
447 $min_length = isset( $block_config['min_length'] ) ? absint( $block_config['min_length'] ) : 0;
448 if ( $min_length > 0 && mb_strlen( $value ) < $min_length ) {
449 $dynamic_messages = Translatable::dynamic_validation_messages();
450 $min_chars_message = isset( $dynamic_messages['srfm_textarea_min_chars'] ) && is_string( $dynamic_messages['srfm_textarea_min_chars'] ) && '' !== $dynamic_messages['srfm_textarea_min_chars']
451 ? $dynamic_messages['srfm_textarea_min_chars']
452 /* translators: %s represents the minimum number of characters required */
453 : __( 'Please enter at least %s characters.', 'sureforms' );
454 $not_valid_fields[ $key ] = sprintf( $min_chars_message, $min_length );
455 }
456 }
457
458 // Email field RFC 5321 length limits (local part / domain), overridable via filter.
459 // Only the main email value is in form data (the confirm input has no `name`),
460 // so the server validates that value; the client mirrors this for both inputs.
461 // Split on the LAST @ per RFC 5321 so the local part may contain a quoted @.
462 $at_pos = is_string( $value ) && '' !== $value ? strrpos( $value, '@' ) : false;
463 if ( 'srfm-email' === $get_field_name && is_string( $value ) && false !== $at_pos ) {
464 $email_limits = self::get_email_char_limits();
465 $local_max = $email_limits['local'];
466 $domain_max = $email_limits['domain'];
467 $local_len = mb_strlen( substr( $value, 0, $at_pos ) );
468 $domain_len = mb_strlen( substr( $value, $at_pos + 1 ) );
469
470 $dynamic_messages = Translatable::dynamic_validation_messages();
471 if ( $local_max > 0 && $local_len > $local_max ) {
472 $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']
473 ? $dynamic_messages['srfm_email_local_max_length']
474 /* translators: %s: maximum characters allowed before the @ symbol. */
475 : __( 'The part before @ may not exceed %s characters.', 'sureforms' );
476 $not_valid_fields[ $key ] = sprintf( $local_message, $local_max );
477 } elseif ( $domain_max > 0 && $domain_len > $domain_max ) {
478 $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']
479 ? $dynamic_messages['srfm_email_domain_max_length']
480 /* translators: %s: maximum characters allowed after the @ symbol. */
481 : __( 'The part after @ may not exceed %s characters.', 'sureforms' );
482 $not_valid_fields[ $key ] = sprintf( $domain_message, $domain_max );
483 }
484 }
485 }
486
487 // Return the array of invalid fields and their error messages.
488 // Example: [ 'srfm-email-c867d9d9-lbl-email' => 'This field is required.' ].
489 return $not_valid_fields;
490 }
491
492 /**
493 * Resolve the Email field character limits (RFC 5321), split on the last @.
494 *
495 * Single source of truth shared by the server validation and the limits localized to the
496 * frontend script, so a filter override applies consistently to both.
497 *
498 * @return array{local:int,domain:int} Resolved limits. A value of 0 disables that check.
499 * @since 2.12.1
500 */
501 public static function get_email_char_limits() {
502 /**
503 * Filters the Email field character limits (RFC 5321).
504 *
505 * @param array $limits {
506 * Character limits for the email value, split on the last @.
507 *
508 * @type int $local Max characters before the @. 0 disables the check. Default 64.
509 * @type int $domain Max characters after the @. 0 disables the check. Default 255.
510 * }
511 * @since 2.12.1
512 */
513 $email_limits = apply_filters(
514 'srfm_email_field_char_limits',
515 [
516 'local' => 64,
517 'domain' => 255,
518 ]
519 );
520
521 return [
522 'local' => isset( $email_limits['local'] ) ? absint( $email_limits['local'] ) : 64,
523 'domain' => isset( $email_limits['domain'] ) ? absint( $email_limits['domain'] ) : 255,
524 ];
525 }
526
527 /**
528 * The walked allowlists for one form, memoised for the request.
529 *
530 * One cache, not one per getter. Both public getters need the same walk, and each
531 * holding its own `static $cache` meant parse_blocks() plus the recursive collect
532 * ran twice for every submission -- once for the ids and again for the slugs --
533 * on a form whose markup can be large.
534 *
535 * Only the walk is memoised. The filtered results deliberately are not: a third
536 * party returning a malformed value would otherwise poison the set for the rest of
537 * the request, and because the lookup short-circuits on isset() the walk would
538 * never be retried.
539 *
540 * @param int $form_id The form post id, already normalised by the callers.
541 * @since 2.12.8
542 * @return array{ids:array<string,true>,slugs:array<string,true>}
543 */
544 private static function get_field_identifiers( $form_id ) {
545 static $cache = [];
546
547 if ( ! isset( $cache[ $form_id ] ) ) {
548 $cache[ $form_id ] = self::walk_field_identifiers( $form_id );
549 }
550
551 return $cache[ $form_id ];
552 }
553
554 /**
555 * Walk a form's blocks once, returning both the block-id and slug allowlists.
556 *
557 * @param int $form_id The form post id.
558 * @since 2.12.7
559 * @return array{ids:array<string,true>,slugs:array<string,true>}
560 */
561 private static function walk_field_identifiers( $form_id ) {
562 $ids = [];
563 $slugs = [];
564 $post = get_post( $form_id );
565
566 if ( $post instanceof \WP_Post && ! empty( $post->post_content ) && function_exists( 'parse_blocks' ) ) {
567 $visited = [];
568 self::collect_field_block_ids( parse_blocks( $post->post_content ), $ids, $slugs, $visited );
569 }
570
571 return [
572 'ids' => $ids,
573 'slugs' => $slugs,
574 ];
575 }
576
577 /**
578 * Whether a submitted field key belongs to the form, by block_id or by slug.
579 *
580 * @param string $key Submitted field key.
581 * @param array<string,true> $known_block_ids Allowlisted block ids.
582 * @param array<string,true> $known_slugs Allowlisted field slugs.
583 * @since 2.12.7
584 * @return bool
585 */
586 private static function field_key_belongs_to_form( $key, $known_block_ids, $known_slugs ) {
587 if ( isset( $known_block_ids[ Helper::get_block_id_from_key( $key ) ] ) ) {
588 return true;
589 }
590
591 $slug = self::get_slug_from_key( $key );
592
593 return '' !== $slug && isset( $known_slugs[ $slug ] );
594 }
595
596 /**
597 * Extract a field's slug from its submitted key.
598 *
599 * A key is `srfm-<type>-<block_id>-lbl-<base64 label>-<slug>`. Helper::encode() is
600 * padding-stripped standard base64 and never contains a hyphen, so the slug is
601 * everything after the first hyphen that follows `-lbl-` (the slug itself may
602 * contain hyphens, which is why only the first is used as the boundary).
603 *
604 * @param string $key Submitted field key.
605 * @since 2.12.7
606 * @return string The slug, or '' when it cannot be derived.
607 */
608 private static function get_slug_from_key( $key ) {
609 if ( ! is_string( $key ) || false === strpos( $key, '-lbl-' ) ) {
610 return '';
611 }
612
613 $parts = explode( '-lbl-', $key );
614 $after = $parts[1] ?? '';
615 $pos = strpos( $after, '-' );
616
617 return false === $pos ? '' : substr( $after, $pos + 1 );
618 }
619
620 /**
621 * Remove unknown child field keys from repeater rows.
622 *
623 * @param array<mixed> $rows The repeater's submitted rows.
624 * @param array<string,true> $known_block_ids Map of block ids belonging to the form.
625 * @param array<string,true> $known_slugs Map of field slugs belonging to the form.
626 * @since 2.12.3
627 * @return array<mixed> The rows with unknown child keys removed.
628 */
629 private static function strip_unknown_repeater_keys( $rows, $known_block_ids, $known_slugs = [] ) {
630 foreach ( $rows as $index => $row ) {
631 if ( ! is_array( $row ) ) {
632 continue;
633 }
634
635 foreach ( array_keys( $row ) as $child_key ) {
636 if ( ! is_string( $child_key ) || false === strpos( $child_key, '-lbl-' ) ) {
637 continue;
638 }
639
640 if ( ! self::field_key_belongs_to_form( $child_key, $known_block_ids, $known_slugs ) ) {
641 unset( $row[ $child_key ] );
642 }
643 }
644
645 $rows[ $index ] = $row;
646 }
647
648 return $rows;
649 }
650
651 /**
652 * Recursively collect SureForms block ids from a parsed block tree.
653 *
654 * @param array<mixed> $blocks Parsed blocks from parse_blocks().
655 * @param array<string,true> $ids Accumulator of block id => true (by reference).
656 * @param array<string,true> $slugs Accumulator of field slug => true (by reference).
657 * @param array<int,true> $visited Expanded reusable-block post ids, guards cycles.
658 * @param int $depth Current recursion depth, guards pathological trees.
659 * @since 2.12.3
660 * @return void
661 */
662 private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visited, $depth = 0 ) {
663 if ( ! is_array( $blocks ) || $depth > 50 ) {
664 return;
665 }
666
667 foreach ( $blocks as $block ) {
668 if ( ! is_array( $block ) ) {
669 continue;
670 }
671
672 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
673 $block_name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
674
675 // Stored raw, deliberately. The lookup side derives the id from the submitted
676 // key via Helper::get_block_id_from_key(), which does not sanitise — putting
677 // sanitize_text_field() only on this side would file any id the sanitiser
678 // alters under a different string than the one looked up, making a legitimate
679 // field permanently unsubmittable. These are map keys used for comparison
680 // only; nothing is echoed from here.
681 if ( 0 === strpos( $block_name, 'srfm/' ) ) {
682 if ( ! empty( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ) {
683 $ids[ $attrs['block_id'] ] = true;
684 }
685 // Also index by slug. The block_id is rebuilt when the editor recreates a
686 // field and can differ between the cached HTML a visitor submitted and the
687 // current post_content, but the slug is the stable field identifier — so a
688 // slug match keeps a legitimately-submitted field whose block_id drifted.
689 if ( ! empty( $attrs['slug'] ) && is_string( $attrs['slug'] ) ) {
690 $slugs[ $attrs['slug'] ] = true;
691 }
692 }
693
694 // Expand reusable/synced patterns so fields living inside a pattern count as
695 // part of the form.
696 if ( 'core/block' === $block_name && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
697 $ref = absint( $attrs['ref'] );
698 if ( $ref > 0 && ! isset( $visited[ $ref ] ) ) {
699 $visited[ $ref ] = true;
700 $ref_post = get_post( $ref );
701 if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) {
702 self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $slugs, $visited, $depth + 1 );
703 }
704 }
705 }
706
707 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
708 self::collect_field_block_ids( $block['innerBlocks'], $ids, $slugs, $visited, $depth + 1 );
709 }
710 }
711 }
712
713 /**
714 * Process payment block configuration.
715 *
716 * @param array<mixed> $attrs Block attributes.
717 * @param array<mixed> $blocks All blocks.
718 * @return array Processed payment configuration.
719 * @since 2.3.0
720 */
721 private static function process_payment_block( $attrs, $blocks ) {
722 $payment_config = [];
723
724 // Extract payment type (single or subscription).
725 $payment_config['payment_type'] = isset( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] ) ? sanitize_text_field( $attrs['paymentType'] ) : 'one-time';
726
727 // Persist subscription plan (interval + billing cycles) for any form that
728 // has a subscription path. The admin picks a single value for each in the
729 // editor; the server uses these stored values as the source of truth on
730 // submit so a tampered interval/cycles in form data cannot redirect Stripe
731 // to a different billing cadence.
732 if ( in_array( $payment_config['payment_type'], [ 'subscription', 'both' ], true ) && isset( $attrs['subscriptionPlan'] ) && is_array( $attrs['subscriptionPlan'] ) ) {
733 if ( isset( $attrs['subscriptionPlan']['interval'] ) && is_string( $attrs['subscriptionPlan']['interval'] ) ) {
734 $payment_config['subscription_interval'] = sanitize_text_field( $attrs['subscriptionPlan']['interval'] );
735 }
736 if ( isset( $attrs['subscriptionPlan']['billingCycles'] ) ) {
737 // billingCycles is either an integer count or the string 'ongoing'.
738 $cycles_raw = $attrs['subscriptionPlan']['billingCycles'];
739 $payment_config['subscription_billing_cycles'] = is_numeric( $cycles_raw ) ? intval( $cycles_raw ) : sanitize_text_field( (string) $cycles_raw );
740 }
741 }
742
743 // Extract amount type (fixed or minimum).
744 $payment_config['amount_type'] = isset( $attrs['amountType'] ) && is_string( $attrs['amountType'] ) ? sanitize_text_field( $attrs['amountType'] ) : 'fixed';
745
746 $payment_config['fixed_amount'] = isset( $attrs['fixedAmount'] ) ? floatval( $attrs['fixedAmount'] ) : 10;
747
748 $payment_config['minimum_amount'] = isset( $attrs['minimumAmount'] ) ? floatval( $attrs['minimumAmount'] ) : 0;
749
750 // Extract variable amount field reference.
751 if ( isset( $attrs['variableAmountField'] ) ) {
752 $variable_amount_slug = sanitize_text_field( $attrs['variableAmountField'] );
753 $payment_config['variable_amount_field'] = $variable_amount_slug;
754
755 // Find and add the block name from which the variable amount field comes from.
756 if ( ! empty( $variable_amount_slug ) && is_array( $blocks ) ) {
757 foreach ( $blocks as $block ) {
758 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $variable_amount_slug ) {
759 $payment_config['variable_amount_field_block_name'] = $block['blockName'];
760 break;
761 }
762 }
763 }
764 }
765
766 // BOTH MODE: store per-type amount configs so server-side validation can
767 // use the correct config based on which flow the user actually chose.
768 if ( 'both' === $payment_config['payment_type'] ) {
769 $payment_config['one_time_amount_type'] = isset( $attrs['oneTimeAmountType'] ) && is_string( $attrs['oneTimeAmountType'] ) ? sanitize_text_field( $attrs['oneTimeAmountType'] ) : 'fixed';
770 $payment_config['one_time_fixed_amount'] = isset( $attrs['oneTimeFixedAmount'] ) ? floatval( $attrs['oneTimeFixedAmount'] ) : 10;
771 $payment_config['one_time_minimum_amount'] = isset( $attrs['oneTimeMinimumAmount'] ) ? floatval( $attrs['oneTimeMinimumAmount'] ) : 0;
772
773 if ( isset( $attrs['oneTimeVariableAmountField'] ) ) {
774 $ot_slug = sanitize_text_field( $attrs['oneTimeVariableAmountField'] );
775 $payment_config['one_time_variable_amount_field'] = $ot_slug;
776 if ( ! empty( $ot_slug ) && is_array( $blocks ) ) {
777 foreach ( $blocks as $block ) {
778 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $ot_slug ) {
779 $payment_config['one_time_variable_amount_field_block_name'] = $block['blockName'];
780 break;
781 }
782 }
783 }
784 }
785
786 $payment_config['subscription_amount_type'] = isset( $attrs['subscriptionAmountType'] ) && is_string( $attrs['subscriptionAmountType'] ) ? sanitize_text_field( $attrs['subscriptionAmountType'] ) : 'fixed';
787 $payment_config['subscription_fixed_amount'] = isset( $attrs['subscriptionFixedAmount'] ) ? floatval( $attrs['subscriptionFixedAmount'] ) : 10;
788 $payment_config['subscription_minimum_amount'] = isset( $attrs['subscriptionMinimumAmount'] ) ? floatval( $attrs['subscriptionMinimumAmount'] ) : 0;
789
790 if ( isset( $attrs['subscriptionVariableAmountField'] ) ) {
791 $sub_slug = sanitize_text_field( $attrs['subscriptionVariableAmountField'] );
792 $payment_config['subscription_variable_amount_field'] = $sub_slug;
793 if ( ! empty( $sub_slug ) && is_array( $blocks ) ) {
794 foreach ( $blocks as $block ) {
795 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $sub_slug ) {
796 $payment_config['subscription_variable_amount_field_block_name'] = $block['blockName'];
797 break;
798 }
799 }
800 }
801 }
802 }
803
804 return $payment_config;
805 }
806
807 /**
808 * Process dropdown block configuration.
809 *
810 * @param array<mixed> $attrs Block attributes.
811 * @return array Processed dropdown configuration.
812 * @since 2.3.0
813 */
814 private static function process_dropdown_block( $attrs ) {
815 $dropdown_config = [];
816
817 // Extract required field.
818 $dropdown_config['required'] = isset( $attrs['required'] ) && ! empty( $attrs['required'] ) ? true : false;
819
820 // Extract options with their full structure (label, icon, value).
821 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
822 $sanitized_options = [];
823 foreach ( $attrs['options'] as $option ) {
824 if ( is_array( $option ) ) {
825 $sanitized_options[] = [
826 'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
827 'icon' => isset( $option['icon'] ) ? sanitize_text_field( $option['icon'] ) : '',
828 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
829 ];
830 }
831 }
832 $dropdown_config['options'] = $sanitized_options;
833 }
834
835 // Extract showValues flag.
836 $dropdown_config['show_values'] = isset( $attrs['showValues'] ) ? rest_sanitize_boolean( $attrs['showValues'] ) : false;
837
838 // Extract multiSelect flag.
839 if ( isset( $attrs['multiSelect'] ) ) {
840 $dropdown_config['multi_select'] = rest_sanitize_boolean( $attrs['multiSelect'] );
841 }
842
843 // Extract minValue for multi-select validation.
844 if ( isset( $attrs['minValue'] ) ) {
845 $dropdown_config['min_value'] = absint( $attrs['minValue'] );
846 }
847
848 // Extract maxValue for multi-select validation.
849 if ( isset( $attrs['maxValue'] ) ) {
850 $dropdown_config['max_value'] = absint( $attrs['maxValue'] );
851 }
852
853 return $dropdown_config;
854 }
855
856 /**
857 * Process multi-choice block configuration.
858 *
859 * @param array<mixed> $attrs Block attributes.
860 * @return array Processed multi-choice configuration.
861 * @since 2.3.0
862 */
863 private static function process_multichoice_block( $attrs ) {
864 $multichoice_config = [];
865
866 // Extract required field.
867 $multichoice_config['required'] = isset( $attrs['required'] ) && ! empty( $attrs['required'] ) ? true : false;
868
869 // Extract singleSelection flag.
870 if ( isset( $attrs['singleSelection'] ) ) {
871 $multichoice_config['single_selection'] = rest_sanitize_boolean( $attrs['singleSelection'] );
872 }
873
874 // Extract minValue for validation.
875 if ( isset( $attrs['minValue'] ) ) {
876 $multichoice_config['min_value'] = absint( $attrs['minValue'] );
877 }
878
879 // Extract maxValue for validation.
880 if ( isset( $attrs['maxValue'] ) ) {
881 $multichoice_config['max_value'] = absint( $attrs['maxValue'] );
882 }
883
884 // Extract options with their full structure (label, icon, value).
885 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
886 $sanitized_options = [];
887 foreach ( $attrs['options'] as $option ) {
888 if ( is_array( $option ) ) {
889 $sanitized_options[] = [
890 'label' => isset( $option['optionTitle'] ) ? trim( sanitize_text_field( $option['optionTitle'] ) ) : '',
891 'icon' => isset( $option['icon'] ) ? sanitize_text_field( $option['icon'] ) : '',
892 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
893 ];
894 }
895 }
896 $multichoice_config['options'] = $sanitized_options;
897 }
898
899 // Extract showValues flag.
900 if ( isset( $attrs['showValues'] ) ) {
901 $multichoice_config['show_values'] = rest_sanitize_boolean( $attrs['showValues'] );
902 }
903
904 return $multichoice_config;
905 }
906
907 /**
908 * Process textarea block configuration.
909 *
910 * @param array<mixed> $attrs Block attributes.
911 * @return array Processed textarea configuration.
912 * @since 2.8.2
913 */
914 private static function process_textarea_block( $attrs ) {
915 // Always emit a min_length key so a cleared/invalid value overwrites any
916 // previously stored config on save instead of falling back to stale data.
917 // Rich-text editors submit HTML markup which would skew mb_strlen counts,
918 // so they're treated as "no min-length validation".
919 if ( ! empty( $attrs['isRichText'] ) ) {
920 return [ 'min_length' => 0 ];
921 }
922
923 $min_length = isset( $attrs['minLength'] ) && is_numeric( $attrs['minLength'] ) ? absint( $attrs['minLength'] ) : 0;
924 $max_length = isset( $attrs['maxLength'] ) && is_numeric( $attrs['maxLength'] ) ? absint( $attrs['maxLength'] ) : 0;
925
926 // Misconfiguration guard — drop min when it exceeds max so the form stays submittable.
927 if ( $max_length > 0 && $min_length > $max_length ) {
928 $min_length = 0;
929 }
930
931 return [ 'min_length' => $min_length ];
932 }
933
934 /**
935 * Process number block configuration.
936 *
937 * @param array<mixed> $attrs Block attributes.
938 * @return array Processed number configuration.
939 * @since 2.4.0
940 */
941 private static function process_number_block( $attrs ) {
942 $number_config = [];
943
944 // Extract required field.
945 if ( isset( $attrs['required'] ) ) {
946 $number_config['required'] = ! empty( $attrs['required'] ) ? true : false;
947 }
948
949 // Extract format type (us-style or eu-style).
950 $number_config['format_type'] = isset( $attrs['formatType'] ) && is_string( $attrs['formatType'] ) ? sanitize_text_field( $attrs['formatType'] ) : 'us-style';
951
952 // Extract min value.
953 if ( isset( $attrs['min'] ) ) {
954 $number_config['min'] = floatval( $attrs['min'] );
955 }
956
957 // Extract max value.
958 if ( isset( $attrs['max'] ) ) {
959 $number_config['max'] = floatval( $attrs['max'] );
960 }
961
962 // Capture calculation config (Pro feature) so a calculation-driven number field used as a
963 // payment amount source can be re-derived server-side instead of trusting the submitted
964 // value. Harmless when the calculation feature is not in use.
965 if ( ! empty( $attrs['enableCalculation'] ) ) {
966 $number_config['enableCalculation'] = true;
967 $number_config['calculationFormula'] = isset( $attrs['calculationFormula'] ) && is_string( $attrs['calculationFormula'] ) ? $attrs['calculationFormula'] : '';
968 // Stored as null when unset so the validator rounds only when a precision is configured.
969 $number_config['calculationRound'] = isset( $attrs['calculationRound'] ) && is_numeric( $attrs['calculationRound'] ) ? absint( $attrs['calculationRound'] ) : null;
970 }
971
972 return $number_config;
973 }
974 }
975