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/field-validation.php +530 -9 2.8.0 → 2.12.8 View file →
@@ -66,8 +66,11 @@
66 66 break;
67 67 case 'srfm/number':
68 68 $processed_config = self::process_number_block( $block['attrs'] );
69 69 break;
70 + case 'srfm/textarea':
71 + $processed_config = self::process_textarea_block( $block['attrs'] );
72 + break;
70 73 }
71 74
72 75 // If block was processed, store its configuration.
73 76 if ( null !== $processed_config && ! empty( $processed_config ) ) {
@@ -90,11 +93,16 @@
90 93 continue;
91 94 }
92 95 }
93 96
94 - // Only update meta if we have processed configurations.
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.
95 101 if ( ! empty( $block_config ) ) {
96 102 update_post_meta( $form_id, '_srfm_block_config', $block_config );
103 + } else {
104 + delete_post_meta( $form_id, '_srfm_block_config' );
97 105 }
98 106 }
99 107
100 108 /**
@@ -188,8 +196,167 @@
188 196 return is_array( $get_form_config ) ? $get_form_config : [];
189 197 }
190 198
191 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 + /**
192 359 * Validate form data for a given form.
193 360 *
194 361 * This function checks each field in the submitted form data (including uploaded files)
195 362 * and applies the 'srfm_validate_form_data' filter to validate each field according to
@@ -225,16 +392,16 @@
225 392 continue;
226 393 }
227 394
228 395 $get_name_with_id = explode( '-lbl-', $key );
229 - // Extract the part after the last '-' in the key, if it matches the pattern.
230 - // Example: $get_name_with_id[0] = "srfm-email-c867d9d9".
231 - // $extracted_id = "c867d9d9".
232 - $extracted_id = '';
233 - if ( is_string( $key ) && preg_match( '/-([a-zA-Z0-9]+)$/', $get_name_with_id[0], $matches ) ) {
234 - $extracted_id = $matches[1];
235 - // Now $extracted_id contains "c867d9d9" for "srfm-email-c867d9d9".
236 - }
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 ) : '';
237 404
238 405 // $get_slug will be the slug after the first hyphen in the second part.
239 406 // Example: $get_name_with_id[1] = "email" or "field-email", $get_slug = "email".
240 407 $get_slug = isset( $get_name_with_id[1] ) ? preg_replace( '/^[^-]+-/', '', $get_name_with_id[1] ) : '';
@@ -272,8 +439,50 @@
272 439 if ( false === $field_validated['validated'] ) {
273 440 $not_valid_fields[ $key ] = $field_validated['error'] ?? __( 'Field is not valid.', 'sureforms' );
274 441 }
275 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 + }
276 485 }
277 486
278 487 // Return the array of invalid fields and their error messages.
279 488 // Example: [ 'srfm-email-c867d9d9-lbl-email' => 'This field is required.' ].
@@ -280,8 +489,229 @@
280 489 return $not_valid_fields;
281 490 }
282 491
283 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 + /**
284 714 * Process payment block configuration.
285 715 *
286 716 * @param array<mixed> $attrs Block attributes.
287 717 * @param array<mixed> $blocks All blocks.
@@ -293,8 +723,24 @@
293 723
294 724 // Extract payment type (single or subscription).
295 725 $payment_config['payment_type'] = isset( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] ) ? sanitize_text_field( $attrs['paymentType'] ) : 'one-time';
296 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 +
297 743 // Extract amount type (fixed or minimum).
298 744 $payment_config['amount_type'] = isset( $attrs['amountType'] ) && is_string( $attrs['amountType'] ) ? sanitize_text_field( $attrs['amountType'] ) : 'fixed';
299 745
300 746 $payment_config['fixed_amount'] = isset( $attrs['fixedAmount'] ) ? floatval( $attrs['fixedAmount'] ) : 10;
@@ -316,8 +762,46 @@
316 762 }
317 763 }
318 764 }
319 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 +
320 804 return $payment_config;
321 805 }
322 806
323 807 /**
@@ -420,8 +904,35 @@
420 904 return $multichoice_config;
421 905 }
422 906
423 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 + /**
424 935 * Process number block configuration.
425 936 *
426 937 * @param array<mixed> $attrs Block attributes.
427 938 * @return array Processed number configuration.
@@ -445,8 +956,18 @@
445 956
446 957 // Extract max value.
447 958 if ( isset( $attrs['max'] ) ) {
448 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;
449 970 }
450 971
451 972 return $number_config;
452 973 }