PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.1
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / field-validation.php

field-validation.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.1, at inc/field-validation.php

1,200 lines 44.4 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 field validation for SureDonation forms.
6 * Stores block configuration on form save and retrieves it for validation.
7 *
8 * @package SureDonation
9 * @since 0.0.1
10 */
11
12 namespace SureDonation\Inc;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18 /**
19 * Field Validation Class
20 */
21 class Field_Validation {
22 /**
23 * Meta key for storing block configuration.
24 *
25 * @since 0.0.1
26 */
27 public const BLOCK_CONFIG_META_KEY = '_suredonation_block_config';
28
29 /**
30 * Key within the consolidated suredonation_options array that stores the
31 * admin-overridden default validation messages (Global Settings → Form
32 * Validation). Per-field messages always take precedence over these.
33 *
34 * @since 1.1.0
35 */
36 public const VALIDATION_MESSAGES_OPTION_KEY = 'validation_messages';
37
38 /**
39 * Field blocks whose values participate in field-level validation.
40 *
41 * @since 1.1.0
42 */
43 public const VALIDATABLE_BLOCKS = [
44 'suredonation/input',
45 'suredonation/email',
46 'suredonation/number',
47 'suredonation/dropdown',
48 'suredonation/phone',
49 'suredonation/url',
50 ];
51
52 /**
53 * Add block configuration for form fields.
54 *
55 * This function processes blocks in a form and stores their configuration as post meta.
56 * It extracts payment block settings (amount type, fixed amount, minimum amount, etc.)
57 * which are used for server-side validation to prevent payment manipulation.
58 *
59 * @param array<mixed> $blocks Array of blocks to process.
60 * @param int $form_id Form post ID.
61 * @return void
62 * @since 0.0.1
63 */
64 public static function add_block_config( $blocks, $form_id ) {
65 // Initialize array to store processed block configurations.
66 $block_config = [];
67
68 // Process blocks recursively.
69 self::process_blocks_recursive( $blocks, $block_config );
70
71 // Only update meta if we have processed configurations.
72 if ( ! empty( $block_config ) ) {
73 update_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, $block_config );
74 }
75 }
76
77 /**
78 * Retrieve or migrate the block configuration for legacy forms.
79 *
80 * This function checks if the _suredonation_block_config post meta exists for the given form ID.
81 * If not found, it attempts to parse the form's post content and generate the block config.
82 *
83 * @param int $form_id The ID of the form post.
84 * @since 0.0.1
85 * @return array<string, array<string, mixed>>|null The block configuration array, or null if not found or invalid.
86 */
87 public static function get_or_migrate_block_config_for_legacy_form( $form_id ) {
88 // Validate that $form_id is a positive integer.
89 if ( ! is_int( $form_id ) || $form_id <= 0 ) {
90 return null;
91 }
92
93 // Retrieve the block config from post meta.
94 $block_config = get_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, true );
95 if ( ! empty( $block_config ) && is_array( $block_config ) ) {
96 // If it exists and is an array, return it directly (no migration needed).
97 return $block_config;
98 }
99
100 // Get the post by ID and validate.
101 $post = get_post( $form_id );
102 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
103 return null;
104 }
105
106 // Parse the blocks from the post content and attempt migration.
107 if ( function_exists( 'parse_blocks' ) ) {
108 $blocks = parse_blocks( $post->post_content );
109 if ( is_array( $blocks ) && ! empty( $blocks ) ) {
110 self::add_block_config( $blocks, $form_id );
111 }
112 }
113
114 // Retrieve the block config again after migration attempt.
115 $block_config = get_post_meta( $form_id, self::BLOCK_CONFIG_META_KEY, true );
116
117 return ! empty( $block_config ) && is_array( $block_config ) ? $block_config : null;
118 }
119
120 /**
121 * Process blocks recursively to extract configuration.
122 *
123 * @param array<mixed> $blocks Array of blocks to process.
124 * @param array<mixed> $block_config Reference to block config array.
125 * @return void
126 * @since 0.0.1
127 */
128 private static function process_blocks_recursive( $blocks, &$block_config ) {
129 foreach ( $blocks as $block ) {
130 // Ensure $block is an array and has the required structure.
131 if ( ! is_array( $block ) ) {
132 continue;
133 }
134
135 // Process inner blocks recursively (for columns, groups, etc.).
136 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
137 self::process_blocks_recursive( $block['innerBlocks'], $block_config );
138 }
139
140 if ( ! isset( $block['blockName'] ) || ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
141 continue;
142 }
143
144 // Validate block_id exists.
145 if ( ! array_key_exists( 'block_id', $block['attrs'] ) || empty( $block['attrs']['block_id'] ) || ! is_string( $block['attrs']['block_id'] ) ) {
146 continue;
147 }
148
149 $block_id = sanitize_text_field( $block['attrs']['block_id'] );
150 $block_name = $block['blockName'];
151
152 // Process specific block types.
153 $processed_config = null;
154
155 switch ( $block_name ) {
156 case 'suredonation/payment':
157 $processed_config = self::process_payment_block( $block['attrs'], $blocks );
158 break;
159 case 'suredonation/donation-amount':
160 $processed_config = self::process_donation_amount_block( $block['attrs'] );
161 break;
162 case 'suredonation/number':
163 $processed_config = self::process_number_block( $block['attrs'] );
164 break;
165 case 'suredonation/cover-fees':
166 $processed_config = self::process_cover_fees_block( $block['attrs'] );
167 break;
168 case 'suredonation/input':
169 $processed_config = self::process_input_block( $block['attrs'] );
170 break;
171 case 'suredonation/email':
172 $processed_config = self::process_email_block( $block['attrs'] );
173 break;
174 case 'suredonation/dropdown':
175 $processed_config = self::process_dropdown_block( $block['attrs'] );
176 break;
177 case 'suredonation/phone':
178 $processed_config = self::process_phone_block( $block['attrs'] );
179 break;
180 case 'suredonation/url':
181 $processed_config = self::process_url_block( $block['attrs'] );
182 break;
183 }
184
185 /**
186 * Filter the stored validation config for a field block.
187 *
188 * Lets extensions contribute configuration for field blocks the
189 * core does not handle (e.g. phone, address, url) so their rules are
190 * persisted on save and picked up by validate_form_data(). Return a
191 * non-empty array (including at least a 'required' flag plus any rule
192 * values the validator needs) to store it under the block id.
193 *
194 * @since 1.1.0
195 * @param array<string, mixed>|null $processed_config Config from core (null when unhandled).
196 * @param string $block_name Block name.
197 * @param array<string, mixed> $attrs Block attributes.
198 * @param array<mixed> $blocks All blocks in the form.
199 */
200 $processed_config = apply_filters( 'suredonation_field_block_config', $processed_config, $block_name, $block['attrs'], $blocks );
201
202 // If block was processed, store its configuration.
203 if ( null !== $processed_config && ! empty( $processed_config ) ) {
204 $processed_config['block_name'] = $block_name;
205
206 // Add the slug to the configuration.
207 if ( isset( $block['attrs']['slug'] ) && ! empty( $block['attrs']['slug'] ) ) {
208 $processed_config['slug'] = sanitize_text_field( $block['attrs']['slug'] );
209 }
210
211 $block_config[ $block_id ] = $processed_config;
212 }
213 }
214 }
215
216 /**
217 * Process payment block configuration.
218 *
219 * Extracts payment-related settings that are needed for server-side validation:
220 * - amount_type: 'fixed' or 'variable'
221 * - fixed_amount: The configured fixed amount
222 * - minimum_amount: The minimum allowed amount for variable amounts
223 * - variable_amount_field: The slug of the field providing the variable amount
224 *
225 * @param array<mixed> $attrs Block attributes.
226 * @param array<mixed> $blocks All blocks in the form.
227 * @return array<string, mixed> Processed payment configuration.
228 * @since 0.0.1
229 */
230 private static function process_payment_block( $attrs, $blocks ) {
231 $payment_config = [];
232
233 // Extract payment type (one-time or subscription).
234 // Default to 'one-time' if not set (Gutenberg may not save default values).
235 $payment_config['payment_type'] = isset( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] )
236 ? sanitize_text_field( $attrs['paymentType'] )
237 : 'one-time';
238
239 // Extract amount type (fixed or variable).
240 // IMPORTANT: Always store this - Gutenberg may not save attributes that match defaults.
241 // Default to 'fixed' which is the block.json default.
242 $payment_config['amount_type'] = isset( $attrs['amountType'] ) && is_string( $attrs['amountType'] )
243 ? sanitize_text_field( $attrs['amountType'] )
244 : 'fixed';
245
246 // Extract configured fixed amount.
247 // Default to 10.00 to match block.json default.
248 $payment_config['fixed_amount'] = isset( $attrs['fixedAmount'] )
249 ? floatval( $attrs['fixedAmount'] )
250 : 10.00;
251
252 // Extract minimum amount for variable amounts.
253 // Defaults to 0 (no minimum) — only enforced if the block setting specifies one.
254 $payment_config['minimum_amount'] = isset( $attrs['minimumAmount'] )
255 ? floatval( $attrs['minimumAmount'] )
256 : 0.0;
257
258 // Extract variable amount field reference.
259 if ( isset( $attrs['variableAmountField'] ) ) {
260 $variable_amount_slug = sanitize_text_field( $attrs['variableAmountField'] );
261 $payment_config['variable_amount_field'] = $variable_amount_slug;
262
263 // Find and add the block name from which the variable amount field comes from.
264 if ( ! empty( $variable_amount_slug ) && is_array( $blocks ) ) {
265 $block_name = self::find_block_name_by_slug( $blocks, $variable_amount_slug );
266 if ( $block_name ) {
267 $payment_config['variable_amount_field_block_name'] = $block_name;
268 }
269 }
270 }
271
272 return $payment_config;
273 }
274
275 /**
276 * Find block name by slug recursively.
277 *
278 * @param array<mixed> $blocks Array of blocks.
279 * @param string $slug Slug to find.
280 * @return string|null Block name if found, null otherwise.
281 * @since 0.0.1
282 */
283 private static function find_block_name_by_slug( $blocks, $slug ) {
284 foreach ( $blocks as $block ) {
285 if ( ! is_array( $block ) ) {
286 continue;
287 }
288
289 // Check inner blocks first.
290 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
291 $found = self::find_block_name_by_slug( $block['innerBlocks'], $slug );
292 if ( $found ) {
293 return $found;
294 }
295 }
296
297 if ( isset( $block['attrs']['slug'] ) && $block['attrs']['slug'] === $slug ) {
298 return $block['blockName'];
299 }
300 }
301 return null;
302 }
303
304 /**
305 * Resolve the slugs of the core donor fields (name, email, variable amount
306 * and the optional mapped phone) from a form's saved payment block.
307 *
308 * These fields are surfaced as first-class donation data and persisted in
309 * their own columns, so the stored "additional" field set omits them.
310 * Deriving the slugs from the saved form here (instead of trusting a
311 * client-supplied list) keeps the exclusion authoritative — a tampered
312 * submission cannot smuggle a core field into the additional set.
313 *
314 * @since 1.1.1
315 * @param int $form_id The donation form post ID.
316 * @return array<int, string> List of core field slugs (empty when none/invalid).
317 */
318 public static function get_core_field_slugs( $form_id ) {
319 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
320 return [];
321 }
322
323 $post = get_post( $form_id );
324 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
325 return [];
326 }
327
328 $payment_attrs = self::find_payment_block_attrs( parse_blocks( $post->post_content ) );
329 if ( empty( $payment_attrs ) ) {
330 return [];
331 }
332
333 $slugs = [];
334 foreach ( [ 'customerNameField', 'customerEmailField', 'customerPhoneField', 'variableAmountField' ] as $attr ) {
335 if ( isset( $payment_attrs[ $attr ] ) && is_string( $payment_attrs[ $attr ] ) ) {
336 $slug = sanitize_text_field( $payment_attrs[ $attr ] );
337 if ( '' !== $slug ) {
338 $slugs[] = $slug;
339 }
340 }
341 }
342
343 return array_values( array_unique( $slugs ) );
344 }
345
346 /**
347 * Find the suredonation/payment block's attributes recursively.
348 *
349 * @since 1.1.1
350 * @param array<mixed> $blocks Array of parsed blocks.
351 * @return array<string, mixed>|null The payment block attributes, or null when absent.
352 */
353 private static function find_payment_block_attrs( $blocks ) {
354 if ( ! is_array( $blocks ) ) {
355 return null;
356 }
357
358 foreach ( $blocks as $block ) {
359 if ( ! is_array( $block ) ) {
360 continue;
361 }
362
363 if ( isset( $block['blockName'] ) && 'suredonation/payment' === $block['blockName'] && isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
364 return $block['attrs'];
365 }
366
367 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
368 $found = self::find_payment_block_attrs( $block['innerBlocks'] );
369 if ( null !== $found ) {
370 return $found;
371 }
372 }
373 }
374
375 return null;
376 }
377
378 /**
379 * Resolve the slug of the field mapped to the donor phone on the payment block.
380 *
381 * The mapping is optional: when an author maps a Phone field via the payment
382 * block's "Customer Phone Field" picker, its value is stored in the dedicated
383 * donor_phone column. Returning the slug lets the submission handlers read the
384 * already-validated value from the submitted fields rather than trusting a
385 * separate client-supplied donor_phone field.
386 *
387 * @since 1.1.1
388 * @param int $form_id The donation form post ID.
389 * @return string The mapped phone field slug, or '' when unset/invalid.
390 */
391 public static function get_mapped_phone_slug( $form_id ) {
392 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
393 return '';
394 }
395
396 $post = get_post( $form_id );
397 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
398 return '';
399 }
400
401 $payment_attrs = self::find_payment_block_attrs( parse_blocks( $post->post_content ) );
402 if ( empty( $payment_attrs ) || ! isset( $payment_attrs['customerPhoneField'] ) || ! is_string( $payment_attrs['customerPhoneField'] ) ) {
403 return '';
404 }
405
406 return sanitize_text_field( $payment_attrs['customerPhoneField'] );
407 }
408
409 /**
410 * Build a map of field slug => label from a form's saved blocks.
411 *
412 * The label persisted with each submitted field is resolved from the saved
413 * form (the authoritative source) rather than scraped from the rendered
414 * page and trusted from the request — mirroring how SureForms recovers a
415 * field's label server-side instead of from client-supplied text. Gutenberg
416 * omits attributes left at their default, so a slug missing from this map
417 * simply has no customized label and the caller falls back to the label
418 * sent with the submission.
419 *
420 * @since 1.1.1
421 * @param int $form_id The donation form post ID.
422 * @return array<string, string> Map of field slug => label (empty when none/invalid).
423 */
424 public static function get_field_labels_map( $form_id ) {
425 if ( ! is_int( $form_id ) || $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
426 return [];
427 }
428
429 $post = get_post( $form_id );
430 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) ) {
431 return [];
432 }
433
434 $labels = [];
435 self::collect_field_labels( parse_blocks( $post->post_content ), $labels );
436
437 return $labels;
438 }
439
440 /**
441 * Recursively collect slug => label pairs from parsed blocks.
442 *
443 * Inner blocks are walked first so nested sub-fields are captured before
444 * their container; the first label seen for a slug wins.
445 *
446 * @since 1.1.1
447 * @param array<mixed> $blocks Parsed blocks.
448 * @param array<string, string> $labels Accumulator passed by reference.
449 * @return void
450 */
451 private static function collect_field_labels( $blocks, &$labels ) {
452 if ( ! is_array( $blocks ) ) {
453 return;
454 }
455
456 foreach ( $blocks as $block ) {
457 if ( ! is_array( $block ) ) {
458 continue;
459 }
460
461 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
462 self::collect_field_labels( $block['innerBlocks'], $labels );
463 }
464
465 if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
466 continue;
467 }
468
469 $slug = isset( $block['attrs']['slug'] ) && is_string( $block['attrs']['slug'] )
470 ? sanitize_text_field( $block['attrs']['slug'] )
471 : '';
472 if ( '' === $slug || isset( $labels[ $slug ] ) ) {
473 continue;
474 }
475
476 if ( isset( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
477 $labels[ $slug ] = sanitize_text_field( $block['attrs']['label'] );
478 }
479 }
480 }
481
482 /**
483 * Process donation-amount block configuration.
484 *
485 * @param array<mixed> $attrs Block attributes.
486 * @return array<string, mixed> Processed donation-amount configuration.
487 * @since 0.0.1
488 */
489 private static function process_donation_amount_block( $attrs ) {
490 $donation_amount_config = [];
491
492 // Extract required field.
493 if ( isset( $attrs['required'] ) ) {
494 $donation_amount_config['required'] = ! empty( $attrs['required'] );
495 }
496
497 // Extract choice type (radio or checkbox).
498 if ( isset( $attrs['choiceType'] ) ) {
499 $donation_amount_config['choice_type'] = sanitize_text_field( $attrs['choiceType'] );
500 }
501
502 // Extract options with their full structure (label, value).
503 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
504 $sanitized_options = [];
505 foreach ( $attrs['options'] as $option ) {
506 if ( is_array( $option ) ) {
507 $sanitized_options[] = [
508 'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
509 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '',
510 ];
511 }
512 }
513 $donation_amount_config['options'] = $sanitized_options;
514 }
515
516 // Custom amount settings (radio-mode only).
517 $donation_amount_config['allow_custom_amount'] = ! isset( $attrs['allowCustomAmount'] ) || ! empty( $attrs['allowCustomAmount'] );
518 $donation_amount_config['custom_amount_min'] = isset( $attrs['customAmountMin'] ) ? (float) $attrs['customAmountMin'] : 0.0;
519 $donation_amount_config['custom_amount_max'] = isset( $attrs['customAmountMax'] ) ? (float) $attrs['customAmountMax'] : 0.0;
520
521 return $donation_amount_config;
522 }
523
524 /**
525 * Process cover-fees block configuration.
526 *
527 * Resolves global vs block-level fee rates and stores them for server-side validation.
528 *
529 * @param array<mixed> $attrs Block attributes.
530 * @return array<string, mixed> Processed cover fees configuration.
531 * @since 1.0.0
532 */
533 private static function process_cover_fees_block( $attrs ) {
534 $use_global = $attrs['useGlobalDefaults'] ?? true;
535
536 if ( $use_global ) {
537 $fee_config = \SureDonation\Inc\Payments\Payment_Helper::get_fee_recovery_settings();
538 } else {
539 $fee_config = [
540 'fee_percentage' => isset( $attrs['feePercentage'] ) ? floatval( $attrs['feePercentage'] ) : 2.9,
541 'fee_fixed' => isset( $attrs['feeFixed'] ) ? floatval( $attrs['feeFixed'] ) : 0.30,
542 'fee_mode' => $attrs['feeMode'] ?? 'all_gateways',
543 'gateways' => $attrs['gatewayFees'] ?? [],
544 ];
545 }
546
547 return [
548 'use_global_defaults' => $use_global,
549 'fee_percentage' => (float) ( $fee_config['fee_percentage'] ?? 2.9 ),
550 'fee_fixed' => (float) ( $fee_config['fee_fixed'] ?? 0.30 ),
551 'fee_mode' => $fee_config['fee_mode'] ?? 'all_gateways',
552 'gateway_fees' => $fee_config['gateways'] ?? [],
553 ];
554 }
555
556 /**
557 * Process number block configuration.
558 *
559 * @param array<mixed> $attrs Block attributes.
560 * @return array<string, mixed> Processed number block configuration.
561 * @since 0.0.1
562 */
563 private static function process_number_block( $attrs ) {
564 $number_config = [];
565
566 // Extract required field.
567 if ( isset( $attrs['required'] ) ) {
568 $number_config['required'] = ! empty( $attrs['required'] );
569 }
570
571 // Extract min value.
572 if ( isset( $attrs['min'] ) ) {
573 $number_config['min'] = floatval( $attrs['min'] );
574 }
575
576 // Extract max value.
577 if ( isset( $attrs['max'] ) ) {
578 $number_config['max'] = floatval( $attrs['max'] );
579 }
580
581 // Field-level min/max value rules for client + server validation.
582 //
583 // These are stored under dedicated keys (read from the block's real
584 // `minValue`/`maxValue` attributes) and are deliberately kept separate
585 // from the amount-path `min`/`max` keys above, which are consumed by
586 // Payment_Helper::validate_number_field_amount(). Coerced with absint to
587 // match Number_Markup, which renders integer min/max — keeping the
588 // rendered HTML constraints and server validation in sync. The markup
589 // mirrors these exact defaults: min is always present (default 1) and
590 // max only applies when greater than zero.
591 $number_config['validation_min'] = isset( $attrs['minValue'] ) ? absint( Helper::get_string_value( $attrs['minValue'] ) ) : 1;
592 $number_config['validation_max'] = isset( $attrs['maxValue'] ) ? absint( Helper::get_string_value( $attrs['maxValue'] ) ) : 0;
593
594 // Per-field custom required message.
595 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
596 if ( '' !== $error_msg ) {
597 $number_config['error_msg'] = $error_msg;
598 }
599
600 return $number_config;
601 }
602
603 /**
604 * Process input (text) block configuration.
605 *
606 * Extracts the field-level validation rules — required, max length and the
607 * optional per-field custom required message — for server-side enforcement.
608 *
609 * @param array<mixed> $attrs Block attributes.
610 * @return array<string, mixed> Processed input block configuration.
611 * @since 1.1.0
612 */
613 private static function process_input_block( $attrs ) {
614 $input_config = [
615 'required' => ! empty( $attrs['required'] ),
616 'max_length' => isset( $attrs['maxLength'] ) ? absint( Helper::get_string_value( $attrs['maxLength'] ) ) : 100,
617 ];
618
619 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
620 if ( '' !== $error_msg ) {
621 $input_config['error_msg'] = $error_msg;
622 }
623
624 return $input_config;
625 }
626
627 /**
628 * Process email block configuration.
629 *
630 * Extracts required state, the optional per-field custom required message
631 * and the per-field invalid-email message for server-side enforcement.
632 *
633 * @param array<mixed> $attrs Block attributes.
634 * @return array<string, mixed> Processed email block configuration.
635 * @since 1.1.0
636 */
637 private static function process_email_block( $attrs ) {
638 $email_config = [
639 'required' => ! empty( $attrs['required'] ),
640 ];
641
642 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
643 if ( '' !== $error_msg ) {
644 $email_config['error_msg'] = $error_msg;
645 }
646
647 $invalid_email_msg = isset( $attrs['invalidEmailMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['invalidEmailMsg'] ) ) : '';
648 if ( '' !== $invalid_email_msg ) {
649 $email_config['invalid_email_msg'] = $invalid_email_msg;
650 }
651
652 return $email_config;
653 }
654
655 /**
656 * Process dropdown block configuration.
657 *
658 * Stores required state, multi-select bounds and the allowed option labels so
659 * the server can enforce required/min/max selections and reject tampered values.
660 *
661 * @param array<mixed> $attrs Block attributes.
662 * @return array<string, mixed> Processed dropdown block configuration.
663 * @since 1.1.1
664 */
665 private static function process_dropdown_block( $attrs ) {
666 $dropdown_config = [
667 'required' => ! empty( $attrs['required'] ),
668 'multi_select' => ! empty( $attrs['multiSelect'] ),
669 'min_selection' => isset( $attrs['minSelection'] ) ? absint( Helper::get_string_value( $attrs['minSelection'] ) ) : 0,
670 'max_selection' => isset( $attrs['maxSelection'] ) ? absint( Helper::get_string_value( $attrs['maxSelection'] ) ) : 0,
671 ];
672
673 // Allowed option labels (the submitted value(s) must match one of these).
674 $options = [];
675 if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
676 foreach ( $attrs['options'] as $option ) {
677 if ( is_array( $option ) && isset( $option['label'] ) && '' !== $option['label'] ) {
678 $options[] = sanitize_text_field( Helper::get_string_value( $option['label'] ) );
679 }
680 }
681 }
682 $dropdown_config['options'] = $options;
683
684 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
685 if ( '' !== $error_msg ) {
686 $dropdown_config['error_msg'] = $error_msg;
687 }
688
689 return $dropdown_config;
690 }
691
692 /**
693 * Process phone block configuration.
694 *
695 * Stores required state and the optional per-field custom required message for
696 * server-side enforcement. Phone-number format is validated loosely (see
697 * validate_field_value) because the submitted value is the E.164-style number
698 * produced by intl-tel-input.
699 *
700 * @param array<mixed> $attrs Block attributes.
701 * @return array<string, mixed> Processed phone block configuration.
702 * @since 1.1.1
703 */
704 private static function process_phone_block( $attrs ) {
705 $phone_config = [
706 'required' => ! empty( $attrs['required'] ),
707 ];
708
709 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
710 if ( '' !== $error_msg ) {
711 $phone_config['error_msg'] = $error_msg;
712 }
713
714 return $phone_config;
715 }
716
717 /**
718 * Process url block configuration.
719 *
720 * Stores required state, the optional per-field custom required message and
721 * the per-field invalid-URL message for server-side enforcement.
722 *
723 * @param array<mixed> $attrs Block attributes.
724 * @return array<string, mixed> Processed url block configuration.
725 * @since 1.1.1
726 */
727 private static function process_url_block( $attrs ) {
728 $url_config = [
729 'required' => ! empty( $attrs['required'] ),
730 ];
731
732 $error_msg = isset( $attrs['errorMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['errorMsg'] ) ) : '';
733 if ( '' !== $error_msg ) {
734 $url_config['error_msg'] = $error_msg;
735 }
736
737 $invalid_url_msg = isset( $attrs['invalidUrlMsg'] ) ? sanitize_text_field( Helper::get_string_value( $attrs['invalidUrlMsg'] ) ) : '';
738 if ( '' !== $invalid_url_msg ) {
739 $url_config['invalid_url_msg'] = $invalid_url_msg;
740 }
741
742 return $url_config;
743 }
744
745 /**
746 * Get the block types that participate in field validation.
747 *
748 * Extensions register new validatable field blocks (e.g. phone, address,
749 * url) via the filter so their values run through validate_form_data().
750 * Pair this with the suredonation_field_block_config filter (to store the
751 * block's rules on save) and suredonation_validate_field (to apply them).
752 *
753 * @return array<int, string>
754 * @since 1.1.0
755 */
756 public static function get_validatable_blocks() {
757 /**
758 * Filter the block types that participate in field validation.
759 *
760 * @since 1.1.0
761 * @param array<int, string> $blocks Validatable block names.
762 */
763 $blocks = apply_filters( 'suredonation_validatable_blocks', self::VALIDATABLE_BLOCKS );
764
765 return is_array( $blocks ) ? $blocks : self::VALIDATABLE_BLOCKS;
766 }
767
768 /**
769 * Validate submitted donation form field values server-side.
770 *
771 * This is the authoritative validation pass: it reads the immutable block
772 * configuration stored on form save and enforces each field's rules
773 * (required, max length, email format, number range). Per-field custom
774 * messages take precedence over the global defaults configured under
775 * Global Settings → Form Validation.
776 *
777 * @param array<string, mixed> $fields Submitted field values keyed by field slug.
778 * @param int $form_id Donation form post ID.
779 * @return array<string, string> Map of field slug => error message. Empty when valid.
780 * @since 1.1.0
781 */
782 public static function validate_form_data( $fields, $form_id ) {
783 $errors = [];
784
785 if ( ! is_array( $fields ) ) {
786 $fields = [];
787 }
788
789 $form_id = absint( $form_id );
790 if ( $form_id <= 0 ) {
791 return $errors;
792 }
793
794 $block_config = self::get_or_migrate_block_config_for_legacy_form( $form_id );
795 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
796 return $errors;
797 }
798
799 $validatable = self::get_validatable_blocks();
800
801 foreach ( $block_config as $config ) {
802 if ( ! is_array( $config ) ) {
803 continue;
804 }
805
806 $block_name = isset( $config['block_name'] ) && is_string( $config['block_name'] ) ? $config['block_name'] : '';
807 $slug = isset( $config['slug'] ) && is_string( $config['slug'] ) ? $config['slug'] : '';
808
809 if ( '' === $slug || ! in_array( $block_name, $validatable, true ) ) {
810 continue;
811 }
812
813 $raw_value = array_key_exists( $slug, $fields ) ? $fields[ $slug ] : '';
814 $value = is_scalar( $raw_value ) ? trim( (string) $raw_value ) : '';
815
816 $error = self::validate_field_value( $block_name, $config, $value );
817
818 /**
819 * Filter the validation error for a single donation form field.
820 *
821 * Lets extensions (e.g. SureDonation Pro) add custom validators for
822 * their own field types or rules. Return a non-empty string to flag
823 * the field as invalid; return an empty string to pass.
824 *
825 * @since 1.1.0
826 * @param string $error Current error message ('' when valid).
827 * @param string $value Submitted, trimmed field value.
828 * @param array<string, mixed> $config Stored block configuration for the field.
829 * @param int $form_id Donation form ID.
830 * @param string $block_name Block name (e.g. 'suredonation/input').
831 */
832 $error = apply_filters( 'suredonation_validate_field', $error, $value, $config, $form_id, $block_name );
833
834 if ( is_string( $error ) && '' !== $error ) {
835 $errors[ $slug ] = $error;
836 }
837 }
838
839 return $errors;
840 }
841
842 /**
843 * Get a validation message by key, preferring the admin override.
844 *
845 * @param string $key Message key.
846 * @return string
847 * @since 1.1.0
848 */
849 public static function get_validation_message( $key ) {
850 $defaults = self::default_validation_messages();
851 $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
852
853 if ( is_array( $stored ) && ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
854 return $stored[ $key ];
855 }
856
857 return isset( $defaults[ $key ] ) ? $defaults[ $key ] : '';
858 }
859
860 /**
861 * Default (fallback) validation messages, keyed by message key.
862 *
863 * Messages containing %s use sprintf substitution for the configured bound.
864 *
865 * @return array<string, string>
866 * @since 1.1.0
867 */
868 public static function default_validation_messages() {
869 $messages = [
870 'suredonation_input_block_required_text' => __( 'This field is required.', 'suredonation' ),
871 'suredonation_email_block_required_text' => __( 'This field is required.', 'suredonation' ),
872 'suredonation_number_block_required_text' => __( 'This field is required.', 'suredonation' ),
873 'suredonation_dropdown_block_required_text' => __( 'This field is required.', 'suredonation' ),
874 'suredonation_phone_block_required_text' => __( 'This field is required.', 'suredonation' ),
875 'suredonation_url_block_required_text' => __( 'This field is required.', 'suredonation' ),
876 'suredonation_valid_email' => __( 'Please enter a valid email address.', 'suredonation' ),
877 'suredonation_valid_number' => __( 'Please enter a valid number.', 'suredonation' ),
878 'suredonation_valid_phone' => __( 'Please enter a valid phone number.', 'suredonation' ),
879 'suredonation_valid_url' => __( 'Please enter a valid URL.', 'suredonation' ),
880 'suredonation_dropdown_invalid_option' => __( 'Please select a valid option.', 'suredonation' ),
881 /* translators: %s: minimum number of selections required. */
882 'suredonation_dropdown_min_selection' => __( 'Please select at least %s option(s).', 'suredonation' ),
883 /* translators: %s: maximum number of selections allowed. */
884 'suredonation_dropdown_max_selection' => __( 'Please select no more than %s option(s).', 'suredonation' ),
885 /* translators: %s: maximum number of characters allowed. */
886 'suredonation_input_max_length' => __( 'Maximum length is %s characters.', 'suredonation' ),
887 /* translators: %s: maximum characters allowed before the @ symbol. */
888 'suredonation_email_local_max_length' => __( 'The part before @ may not exceed %s characters.', 'suredonation' ),
889 /* translators: %s: maximum characters allowed after the @ symbol. */
890 'suredonation_email_domain_max_length' => __( 'The part after @ may not exceed %s characters.', 'suredonation' ),
891 /* translators: %s: maximum total characters allowed in an email address. */
892 'suredonation_email_max_length' => __( 'The email address may not exceed %s characters.', 'suredonation' ),
893 /* translators: %s: minimum allowed value. */
894 'suredonation_input_min_value' => __( 'Minimum value is %s.', 'suredonation' ),
895 /* translators: %s: maximum allowed value. */
896 'suredonation_input_max_value' => __( 'Maximum value is %s.', 'suredonation' ),
897 ];
898
899 /**
900 * Filter the default validation messages.
901 *
902 * Extensions add message keys for their own field types here so the
903 * messages resolve, localize and surface in the Form Validation tab
904 * alongside the core ones. Keys containing %s use sprintf substitution.
905 *
906 * @since 1.1.0
907 * @param array<string, string> $messages Default messages keyed by message key.
908 */
909 return apply_filters( 'suredonation_default_validation_messages', $messages );
910 }
911
912 /**
913 * Get the fully resolved validation messages (admin overrides over defaults).
914 *
915 * Used to localize the messages to the frontend so client-side validation
916 * mirrors exactly what the server enforces.
917 *
918 * @return array<string, string>
919 * @since 1.1.0
920 */
921 public static function get_resolved_validation_messages() {
922 $defaults = self::default_validation_messages();
923 $stored = Helper::get_suredonation_option( self::VALIDATION_MESSAGES_OPTION_KEY, [] );
924
925 if ( ! is_array( $stored ) ) {
926 return $defaults;
927 }
928
929 $resolved = $defaults;
930 foreach ( $defaults as $key => $default ) {
931 if ( ! empty( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
932 $resolved[ $key ] = $stored[ $key ];
933 }
934 }
935
936 return $resolved;
937 }
938
939 /**
940 * Apply the core validation rules for a single field value.
941 *
942 * @param string $block_name Block name.
943 * @param array<string, mixed> $config Stored block configuration for the field.
944 * @param string $value Submitted, trimmed field value.
945 * @return string Error message, or '' when the value passes.
946 * @since 1.1.0
947 */
948 private static function validate_field_value( $block_name, $config, $value ) {
949 // Required check applies to every field type.
950 if ( ! empty( $config['required'] ) && '' === $value ) {
951 return self::resolve_required_message( $block_name, $config );
952 }
953
954 // Format/range checks are skipped for empty optional values.
955 if ( '' === $value ) {
956 return '';
957 }
958
959 switch ( $block_name ) {
960 case 'suredonation/input':
961 $max_length = isset( $config['max_length'] ) && is_numeric( $config['max_length'] ) ? (int) $config['max_length'] : 0;
962 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
963 if ( $max_length > 0 && $length > $max_length ) {
964 // str_replace (not sprintf) because the message is admin/translator
965 // editable; a stray literal % would make sprintf throw on PHP 8.
966 return str_replace( '%s', number_format_i18n( $max_length ), self::get_validation_message( 'suredonation_input_max_length' ) );
967 }
968 break;
969
970 case 'suredonation/email':
971 if ( ! is_email( $value ) ) {
972 if ( ! empty( $config['invalid_email_msg'] ) && is_string( $config['invalid_email_msg'] ) ) {
973 return $config['invalid_email_msg'];
974 }
975 return self::get_validation_message( 'suredonation_valid_email' );
976 }
977
978 $email_length_error = self::validate_email_length( $value );
979 if ( '' !== $email_length_error ) {
980 return $email_length_error;
981 }
982 break;
983
984 case 'suredonation/url':
985 // Intentional dotted-host-only restriction (same as SureForms): the
986 // value must be a domain with a TLD or an IPv4 host, with an optional
987 // scheme, port, path, query and fragment. Bare single-label hosts
988 // (localhost, intranet names, typos like "abcdef") are deliberately
989 // rejected for a public "website" field. The 2048-byte cap
990 // short-circuits before the regex on overlong, public, unauthenticated
991 // input so its host-label sub-pattern cannot backtrack (ReDoS guard).
992 // Kept in sync with the client check in src/form-frontend/validation.js.
993 if ( strlen( $value ) > 2048 || ! preg_match( '#^(https?://)?((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(:\d+)?(/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$#i', $value ) ) {
994 if ( ! empty( $config['invalid_url_msg'] ) && is_string( $config['invalid_url_msg'] ) ) {
995 return $config['invalid_url_msg'];
996 }
997 return self::get_validation_message( 'suredonation_valid_url' );
998 }
999 break;
1000
1001 case 'suredonation/phone':
1002 // Loose format check: digits plus the common phone punctuation,
1003 // 6–20 characters. The strict country-aware check happens client-side
1004 // via intl-tel-input; this guards against obviously bad submissions.
1005 if ( ! preg_match( '/^[\d\s()+.\-]{6,20}$/', $value ) ) {
1006 return self::get_validation_message( 'suredonation_valid_phone' );
1007 }
1008 break;
1009
1010 case 'suredonation/number':
1011 if ( ! is_numeric( $value ) ) {
1012 return self::get_validation_message( 'suredonation_valid_number' );
1013 }
1014
1015 $number = (float) $value;
1016
1017 if ( isset( $config['validation_min'] ) && is_numeric( $config['validation_min'] ) && $number < (float) $config['validation_min'] ) {
1018 return str_replace( '%s', self::format_number( (float) $config['validation_min'] ), self::get_validation_message( 'suredonation_input_min_value' ) );
1019 }
1020
1021 $validation_max = isset( $config['validation_max'] ) && is_numeric( $config['validation_max'] ) ? (float) $config['validation_max'] : 0.0;
1022 if ( $validation_max > 0 && $number > $validation_max ) {
1023 return str_replace( '%s', self::format_number( $validation_max ), self::get_validation_message( 'suredonation_input_max_value' ) );
1024 }
1025 break;
1026
1027 case 'suredonation/dropdown':
1028 $multi_select = ! empty( $config['multi_select'] );
1029 // Deduplicate before the min/max count check — the server is the
1030 // trust boundary, and a crafted "A|A|A" must not pass max_selection
1031 // (or min_selection) with a single distinct value.
1032 $selections = $multi_select
1033 ? array_values( array_unique( array_filter( array_map( 'trim', explode( '|', $value ) ), 'strlen' ) ) )
1034 : [ $value ];
1035
1036 // Reject values that are not among the configured options.
1037 $allowed = isset( $config['options'] ) && is_array( $config['options'] ) ? $config['options'] : [];
1038 if ( ! empty( $allowed ) ) {
1039 foreach ( $selections as $selection ) {
1040 if ( ! in_array( $selection, $allowed, true ) ) {
1041 return self::get_validation_message( 'suredonation_dropdown_invalid_option' );
1042 }
1043 }
1044 }
1045
1046 // Min/max apply to multi-select only.
1047 if ( $multi_select ) {
1048 $count = count( $selections );
1049 $min = isset( $config['min_selection'] ) ? (int) $config['min_selection'] : 0;
1050 $max = isset( $config['max_selection'] ) ? (int) $config['max_selection'] : 0;
1051
1052 if ( $min > 0 && $count < $min ) {
1053 return str_replace( '%s', number_format_i18n( $min ), self::get_validation_message( 'suredonation_dropdown_min_selection' ) );
1054 }
1055 if ( $max > 0 && $count > $max ) {
1056 return str_replace( '%s', number_format_i18n( $max ), self::get_validation_message( 'suredonation_dropdown_max_selection' ) );
1057 }
1058 }
1059 break;
1060 }
1061
1062 return '';
1063 }
1064
1065 /**
1066 * Enforce RFC 5321 length limits on an email value.
1067 *
1068 * The value is split on the last @ so the local part (before @, max 64) and
1069 * domain part (after @, max 255) are bounded separately. Limits are
1070 * overridable via the suredonation_email_field_char_limits filter.
1071 *
1072 * Public so the payment layer can length-cap the persisted donor_email
1073 * (which is separate from the validation-only fields[] copy this class
1074 * normally inspects). A value with no @ — possible when the caller has not
1075 * already run is_email() — is bounded by the local-part limit so oversized
1076 * junk still cannot be stored.
1077 *
1078 * @param string $value Submitted, trimmed email value.
1079 * @return string Error message, or '' when the value passes.
1080 * @since 1.1.1
1081 */
1082 public static function validate_email_length( $value ) {
1083 $defaults = [
1084 'local' => 64,
1085 'domain' => 255,
1086 ];
1087
1088 /**
1089 * Filter the RFC 5321 character limits enforced on the Email field.
1090 *
1091 * @since 1.1.1
1092 * @param array{local:int,domain:int} $limits Max characters for the local and domain parts.
1093 */
1094 $limits = apply_filters( 'suredonation_email_field_char_limits', $defaults );
1095
1096 // Fall back to defaults if the filter returns junk or non-positive values.
1097 $local_limit = is_array( $limits ) && isset( $limits['local'] ) && (int) $limits['local'] > 0 ? (int) $limits['local'] : $defaults['local'];
1098 $domain_limit = is_array( $limits ) && isset( $limits['domain'] ) && (int) $limits['domain'] > 0 ? (int) $limits['domain'] : $defaults['domain'];
1099
1100 $at = strrpos( $value, '@' );
1101 if ( false === $at ) {
1102 // No @ (caller did not run is_email first): bound the whole value by
1103 // the local-part limit so oversized junk cannot be persisted.
1104 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
1105 if ( $length > $local_limit ) {
1106 return str_replace( '%s', number_format_i18n( $local_limit ), self::get_validation_message( 'suredonation_email_local_max_length' ) );
1107 }
1108 // Defensive total cap (see below): only reachable if a filter raised the
1109 // local limit past 254; keeps a no-@ value within the VARCHAR(255) column.
1110 if ( $length > 254 ) {
1111 return str_replace( '%s', number_format_i18n( 254 ), self::get_validation_message( 'suredonation_email_max_length' ) );
1112 }
1113 return '';
1114 }
1115
1116 $local_part = substr( $value, 0, $at );
1117 $domain_part = substr( $value, $at + 1 );
1118
1119 $local_length = function_exists( 'mb_strlen' ) ? mb_strlen( $local_part ) : strlen( $local_part );
1120 $domain_length = function_exists( 'mb_strlen' ) ? mb_strlen( $domain_part ) : strlen( $domain_part );
1121
1122 // str_replace (not sprintf) because the message is admin/translator
1123 // editable; a stray literal % would make sprintf throw on PHP 8.
1124 if ( $local_length > $local_limit ) {
1125 return str_replace( '%s', number_format_i18n( $local_limit ), self::get_validation_message( 'suredonation_email_local_max_length' ) );
1126 }
1127
1128 if ( $domain_length > $domain_limit ) {
1129 return str_replace( '%s', number_format_i18n( $domain_limit ), self::get_validation_message( 'suredonation_email_domain_max_length' ) );
1130 }
1131
1132 // RFC 5321 §4.5.3.1.3: the whole address may not exceed 254 chars. This is a
1133 // fixed cap (independent of the per-part filter) because it also guarantees
1134 // the value fits the VARCHAR(255) donor_email/email columns, which the
1135 // per-part caps alone do not — they sum to 320.
1136 if ( ( $local_length + 1 + $domain_length ) > 254 ) {
1137 return str_replace( '%s', number_format_i18n( 254 ), self::get_validation_message( 'suredonation_email_max_length' ) );
1138 }
1139
1140 return '';
1141 }
1142
1143 /**
1144 * Resolve the required-error message for a field.
1145 *
1146 * Resolution order: per-field custom message → global default for the field
1147 * type (Global Settings → Form Validation) → generic fallback. The message
1148 * key is derived from the block name by convention, so new field blocks need
1149 * no code change here — they only register their default message and tab
1150 * field (e.g. 'suredonation/phone' → 'suredonation_phone_block_required_text').
1151 *
1152 * @param string $block_name Block name.
1153 * @param array<string, mixed> $config Stored block configuration for the field.
1154 * @return string
1155 * @since 1.1.0
1156 */
1157 private static function resolve_required_message( $block_name, $config ) {
1158 if ( ! empty( $config['error_msg'] ) && is_string( $config['error_msg'] ) ) {
1159 return $config['error_msg'];
1160 }
1161
1162 $message = self::get_validation_message( self::required_message_key( $block_name ) );
1163
1164 return '' !== $message ? $message : __( 'This field is required.', 'suredonation' );
1165 }
1166
1167 /**
1168 * Derive the required-message key for a block name.
1169 *
1170 * 'suredonation/input' => 'suredonation_input_block_required_text'.
1171 *
1172 * @param string $block_name Block name.
1173 * @return string
1174 * @since 1.1.0
1175 */
1176 public static function required_message_key( $block_name ) {
1177 $short = str_replace( 'suredonation/', '', (string) $block_name );
1178 $short = (string) preg_replace( '/[^a-z0-9_]+/', '_', strtolower( $short ) );
1179
1180 return 'suredonation_' . $short . '_block_required_text';
1181 }
1182
1183 /**
1184 * Format a numeric bound for display in a validation message.
1185 *
1186 * Drops the decimal portion for whole numbers (e.g. 10.0 → "10").
1187 *
1188 * @param float $number Number to format.
1189 * @return string
1190 * @since 1.1.0
1191 */
1192 private static function format_number( $number ) {
1193 if ( floor( $number ) === $number ) {
1194 return number_format_i18n( $number );
1195 }
1196
1197 return number_format_i18n( $number, 2 );
1198 }
1199 }
1200