PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / trunk
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More vtrunk
4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-field-validator.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 2 weeks ago class-form-captcha-handler.php 1 week ago class-form-controller.php 2 weeks ago class-form-email-config-check.php 2 weeks ago class-form-email-handler.php 2 weeks ago class-form-encryption.php 2 weeks ago class-form-exporter.php 2 weeks ago class-form-field-validator.php 2 weeks ago class-form-file-handler.php 1 week ago class-form-google-auth.php 2 weeks ago class-form-integration-handler.php 2 weeks ago class-form-math-parser.php 2 weeks ago class-form-permissions.php 2 weeks ago class-form-registry.php 2 weeks ago class-form-settings.php 2 weeks ago class-form-submission-cpt.php 2 weeks ago class-form-submission-handler.php 2 weeks ago
class-form-field-validator.php
668 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 class FormFieldValidator
8 {
9 /**
10 * Validate submitted fields against server-side form config.
11 *
12 * @param array $submitted_fields field_id => value from submission
13 * @param array $form_fields_config Array of field attribute arrays from form config
14 * @param string $default_required_message Form-wide message for empty required fields, '' for the localized default
15 * @return array ('fields' => cleaned fields, 'errors' => field_id => error message)
16 */
17 public static function Validate($submitted_fields, $form_fields_config, $default_required_message = '')
18 {
19 // Build lookup: fieldId => config
20 $config_lookup = array();
21 foreach ($form_fields_config as $field_def) {
22 if (isset($field_def['fieldId']) && $field_def['fieldId'] !== '') {
23 $config_lookup[$field_def['fieldId']] = $field_def;
24 }
25 }
26
27 $cleaned = array();
28 $errors = array();
29
30 // Only accept known field IDs
31 foreach ($submitted_fields as $key => $value) {
32 if (isset($config_lookup[$key])) {
33 $cleaned[$key] = $value;
34 }
35 }
36
37 // Validate each configured field
38 foreach ($config_lookup as $field_id => $field_config) {
39 $field_type = isset($field_config['fieldType']) ? $field_config['fieldType'] : 'text';
40
41 // Hidden fields: override with server-side default, skip validation
42 if ($field_type === 'hidden') {
43 $cleaned[$field_id] = isset($field_config['defaultValue']) ? $field_config['defaultValue'] : '';
44 continue;
45 }
46
47 // File fields: validate via FormFileHandler (separate from text validation)
48 if ($field_type === 'file') {
49 $file_error = FormFileHandler::ValidateFiles($field_config, $default_required_message);
50 if ($file_error !== '') {
51 $errors[$field_id] = $file_error;
52 }
53 // Remove file field from text fields — files are handled separately
54 unset($cleaned[$field_id]);
55 continue;
56 }
57
58 $value = isset($cleaned[$field_id]) ? $cleaned[$field_id] : '';
59 $required = !empty($field_config['required']);
60
61 // Conditional logic: if field has active rules and was not submitted, skip required check
62 if ($required && $value === '' && self::HasConditionalLogic($field_config)) {
63 continue;
64 }
65
66 $error = self::ValidateField($value, $field_config, $default_required_message);
67
68 /**
69 * Filter the validation error for a field.
70 * Allows premium plugin to add custom validation for new field types.
71 *
72 * @param string $error Error message, empty if valid.
73 * @param string $value Submitted field value.
74 * @param array $field_config Field configuration from server-side config.
75 */
76 $error = apply_filters('superbaddons_form_validate_field', $error, $value, $field_config);
77
78 if ($error !== '') {
79 $errors[$field_id] = $error;
80 }
81 }
82
83 return array(
84 'fields' => $cleaned,
85 'errors' => $errors,
86 );
87 }
88
89 /**
90 * Validate a single field value against its config.
91 *
92 * @param string $value Submitted value
93 * @param array $config Field config array
94 * @param string $default_required_message Form-wide message for empty required fields, '' for the localized default
95 * @return string Error message, empty if valid
96 */
97 private static function ValidateField($value, $config, $default_required_message = '')
98 {
99 $field_type = isset($config['fieldType']) ? $config['fieldType'] : 'text';
100 $required = !empty($config['required']);
101
102 // Required check
103 if ($required && ($value === '' || $value === null)) {
104 return self::GetRequiredMessage($config, $default_required_message);
105 }
106
107 // Skip further validation if empty and not required
108 if ($value === '' || $value === null) {
109 return '';
110 }
111
112 // Dispatch by field type
113 switch ($field_type) {
114 case 'text':
115 case 'phone':
116 return self::ValidateTextLike($value, $config);
117
118 case 'email':
119 return self::ValidateEmail($value, $config);
120
121 case 'url':
122 return self::ValidateUrl($value, $config);
123
124 case 'textarea':
125 return self::ValidateTextarea($value, $config);
126
127 case 'number':
128 return self::ValidateNumber($value, $config);
129
130 case 'select':
131 case 'radio':
132 return self::ValidateSingleOption($value, $config);
133
134 case 'checkbox':
135 return self::ValidateCheckbox($value, $config);
136
137 case 'consent':
138 // Only the fixed marker value is legitimate ("Checked" — kept
139 // in English so stored submissions and exports are stable
140 // across locales). Anything else is a forged submission.
141 return $value === 'Checked' ? '' : __('Invalid selection.', 'superb-blocks');
142
143 case 'date':
144 return self::ValidateDate($value, $config);
145
146 case 'time':
147 return self::ValidateTime($value, $config);
148
149 case 'rating':
150 return self::ValidateRating($value, $config);
151
152 case 'calculated':
153 // Calculated fields are validated by server-side recalculation (see FormController)
154 return '';
155
156 case 'slider':
157 return self::ValidateSlider($value, $config);
158
159 case 'signature':
160 return self::ValidateSignature($value);
161
162 case 'colorpicker':
163 return self::ValidateColor($value);
164
165 default:
166 return '';
167 }
168 }
169
170 /**
171 * Validate text-like fields (text, phone): length + pattern.
172 */
173 private static function ValidateTextLike($value, $config)
174 {
175 $error = self::CheckLength($value, $config);
176 if ($error !== '') {
177 return $error;
178 }
179
180 return self::CheckPattern($value, $config);
181 }
182
183 /**
184 * Validate email: length + pattern + is_email().
185 */
186 private static function ValidateEmail($value, $config)
187 {
188 $error = self::CheckLength($value, $config);
189 if ($error !== '') {
190 return $error;
191 }
192
193 $error = self::CheckPattern($value, $config);
194 if ($error !== '') {
195 return $error;
196 }
197
198 if (!is_email($value)) {
199 return __('Please enter a valid email address.', 'superb-blocks');
200 }
201
202 return '';
203 }
204
205 /**
206 * Validate URL: length + pattern + esc_url_raw check.
207 */
208 private static function ValidateUrl($value, $config)
209 {
210 $error = self::CheckLength($value, $config);
211 if ($error !== '') {
212 return $error;
213 }
214
215 $error = self::CheckPattern($value, $config);
216 if ($error !== '') {
217 return $error;
218 }
219
220 if (esc_url_raw($value) === '') {
221 return __('Please enter a valid URL.', 'superb-blocks');
222 }
223
224 return '';
225 }
226
227 /**
228 * Validate textarea: length only (no pattern per field type spec).
229 */
230 private static function ValidateTextarea($value, $config)
231 {
232 return self::CheckLength($value, $config);
233 }
234
235 /**
236 * Validate number: is_numeric + min/max value.
237 */
238 private static function ValidateNumber($value, $config)
239 {
240 if (!is_numeric($value)) {
241 return __('Please enter a valid number.', 'superb-blocks');
242 }
243
244 $num = floatval($value);
245
246 $min = isset($config['minValue']) ? $config['minValue'] : null;
247 $max = isset($config['maxValue']) ? $config['maxValue'] : null;
248
249 if ($min !== null && $min !== '' && $num < floatval($min)) {
250 /* translators: %s: minimum allowed numeric value */
251 return sprintf(__('Minimum value is %s.', 'superb-blocks'), $min);
252 }
253
254 if ($max !== null && $max !== '' && $num > floatval($max)) {
255 /* translators: %s: maximum allowed numeric value */
256 return sprintf(__('Maximum value is %s.', 'superb-blocks'), $max);
257 }
258
259 return '';
260 }
261
262 /**
263 * Validate select/radio: value must be in configured options.
264 */
265 private static function ValidateSingleOption($value, $config)
266 {
267 $options = isset($config['options']) && is_array($config['options']) ? $config['options'] : array();
268 $allowed = array();
269 foreach ($options as $opt) {
270 if (isset($opt['value'])) {
271 $allowed[] = $opt['value'];
272 }
273 }
274
275 if (!in_array($value, $allowed, true)) {
276 return __('Invalid selection.', 'superb-blocks');
277 }
278
279 return '';
280 }
281
282 /**
283 * Validate checkbox: comma-separated values, each must be in configured options.
284 */
285 private static function ValidateCheckbox($value, $config)
286 {
287 $options = isset($config['options']) && is_array($config['options']) ? $config['options'] : array();
288 $allowed = array();
289 foreach ($options as $opt) {
290 if (isset($opt['value'])) {
291 $allowed[] = $opt['value'];
292 }
293 }
294
295 // Client sends checkbox values as "Value A, Value B"
296 $selected = array_map('trim', explode(',', $value));
297 foreach ($selected as $sel) {
298 if ($sel !== '' && !in_array($sel, $allowed, true)) {
299 return __('Invalid selection.', 'superb-blocks');
300 }
301 }
302
303 return '';
304 }
305
306 /**
307 * Validate date: format + constraints (future/past/range) + excludeDays.
308 */
309 private static function ValidateDate($value, $config)
310 {
311 // Validate format YYYY-MM-DD
312 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
313 return __('Invalid date format.', 'superb-blocks');
314 }
315
316 $parts = explode('-', $value);
317 if (!checkdate(intval($parts[1]), intval($parts[2]), intval($parts[0]))) {
318 return __('Invalid date.', 'superb-blocks');
319 }
320
321 $ds = isset($config['dateSettings']) && is_array($config['dateSettings']) ? $config['dateSettings'] : array();
322 $constraint = isset($ds['dateConstraint']) ? $ds['dateConstraint'] : 'none';
323 if ($constraint === 'none') {
324 return '';
325 }
326
327 $custom_msg = isset($ds['dateTimeMessage']) && $ds['dateTimeMessage'] !== ''
328 ? $ds['dateTimeMessage']
329 : '';
330
331 // Resolve min/max dates using site timezone (matches client behavior)
332 $today = current_time('Y-m-d');
333 $min_date = null;
334 $max_date = null;
335
336 if ($constraint === 'future') {
337 $min_date = $today;
338 } elseif ($constraint === 'past') {
339 $max_date = $today;
340 } elseif ($constraint === 'range') {
341 $resolved = self::ResolveDateRange($ds, $today);
342 $min_date = $resolved['min'];
343 $max_date = $resolved['max'];
344 }
345
346 if ($min_date !== null && $value < $min_date) {
347 if ($custom_msg !== '') {
348 return $custom_msg;
349 }
350 /* translators: %s: earliest allowed date in Y-m-d format */
351 return sprintf(__('Please select a date on or after %s.', 'superb-blocks'), $min_date);
352 }
353
354 if ($max_date !== null && $value > $max_date) {
355 if ($custom_msg !== '') {
356 return $custom_msg;
357 }
358 /* translators: %s: latest allowed date in Y-m-d format */
359 return sprintf(__('Please select a date on or before %s.', 'superb-blocks'), $max_date);
360 }
361
362 // Exclude days
363 $exclude_days = isset($ds['excludeDays']) && is_array($ds['excludeDays']) ? $ds['excludeDays'] : array();
364 if (!empty($exclude_days)) {
365 $timestamp = strtotime($value . ' UTC');
366 if ($timestamp !== false) {
367 $day_of_week = intval(gmdate('w', $timestamp)); // 0 = Sunday
368 if (in_array($day_of_week, array_map('intval', $exclude_days), true)) {
369 if ($custom_msg !== '') {
370 return $custom_msg;
371 }
372 return __('The selected day of the week is not allowed.', 'superb-blocks');
373 }
374 }
375 }
376
377 return '';
378 }
379
380 /**
381 * Resolve min/max date for range constraint.
382 * Mirrors JS initDateConstraints() in form.js.
383 *
384 * @param array $config Field config
385 * @param string $today Today's date in Y-m-d
386 * @return array ('min' => string|null, 'max' => string|null)
387 */
388 private static function ResolveDateRange($config, $today)
389 {
390 $min_date_mode = isset($config['minDateMode']) ? $config['minDateMode'] : 'none';
391 $max_date_mode = isset($config['maxDateMode']) ? $config['maxDateMode'] : 'none';
392 $min_date = null;
393 $max_date = null;
394
395 // First pass: resolve independent dates (specific and relative)
396 if ($min_date_mode === 'specific') {
397 $min_date = isset($config['minDate']) && $config['minDate'] !== '' ? $config['minDate'] : null;
398 } elseif ($min_date_mode === 'relative') {
399 $offset = isset($config['minDateOffset']) ? intval($config['minDateOffset']) : 0;
400 $min_date = self::OffsetDate($today, $offset);
401 }
402
403 if ($max_date_mode === 'specific') {
404 $max_date = isset($config['maxDate']) && $config['maxDate'] !== '' ? $config['maxDate'] : null;
405 } elseif ($max_date_mode === 'relative') {
406 $offset = isset($config['maxDateOffset']) ? intval($config['maxDateOffset']) : 0;
407 $max_date = self::OffsetDate($today, $offset);
408 }
409
410 // Second pass: resolve offsets that depend on the other date
411 if ($max_date_mode === 'offset') {
412 $offset = isset($config['maxDateOffset']) ? intval($config['maxDateOffset']) : 0;
413 $base = $min_date !== null ? $min_date : $today;
414 $max_date = self::OffsetDate($base, $offset);
415 }
416
417 if ($min_date_mode === 'offset') {
418 $offset = isset($config['minDateOffset']) ? intval($config['minDateOffset']) : 0;
419 $base = $max_date !== null ? $max_date : $today;
420 $min_date = self::OffsetDate($base, -$offset);
421 }
422
423 return array('min' => $min_date, 'max' => $max_date);
424 }
425
426 /**
427 * Add days offset to a date string.
428 *
429 * @param string $date Y-m-d format
430 * @param int $days Number of days (can be negative)
431 * @return string Y-m-d
432 */
433 private static function OffsetDate($date, $days)
434 {
435 $sign = $days >= 0 ? '+' : '';
436 $ts = strtotime($date . ' UTC ' . $sign . $days . ' days');
437 return $ts !== false ? gmdate('Y-m-d', $ts) : $date;
438 }
439
440 /**
441 * Validate time: format + min/max constraints.
442 */
443 private static function ValidateTime($value, $config)
444 {
445 // Validate format HH:MM
446 if (!preg_match('/^\d{2}:\d{2}$/', $value)) {
447 return __('Invalid time format.', 'superb-blocks');
448 }
449
450 $ts = isset($config['timeSettings']) && is_array($config['timeSettings']) ? $config['timeSettings'] : array();
451
452 $custom_msg = isset($ts['dateTimeMessage']) && $ts['dateTimeMessage'] !== ''
453 ? $ts['dateTimeMessage']
454 : '';
455
456 $min_time = isset($ts['minTime']) && $ts['minTime'] !== '' ? $ts['minTime'] : null;
457 $max_time = isset($ts['maxTime']) && $ts['maxTime'] !== '' ? $ts['maxTime'] : null;
458
459 // HH:MM string comparison is lexicographically correct
460 if ($min_time !== null && $value < $min_time) {
461 if ($custom_msg !== '') {
462 return $custom_msg;
463 }
464 /* translators: %s: earliest allowed time in HH:MM format */
465 return sprintf(__('Please select a time at or after %s.', 'superb-blocks'), $min_time);
466 }
467
468 if ($max_time !== null && $value > $max_time) {
469 if ($custom_msg !== '') {
470 return $custom_msg;
471 }
472 /* translators: %s: latest allowed time in HH:MM format */
473 return sprintf(__('Please select a time at or before %s.', 'superb-blocks'), $max_time);
474 }
475
476 return '';
477 }
478
479 /**
480 * Validate signature: must be a valid PNG data URL within size limit.
481 */
482 private static function ValidateSignature($value)
483 {
484 // Max 500KB
485 if (strlen($value) > 500000) {
486 return __('Signature data is too large.', 'superb-blocks');
487 }
488
489 // Must start with PNG data URL prefix
490 $prefix = 'data:image/png;base64,';
491 if (strpos($value, $prefix) !== 0) {
492 return __('Invalid signature format.', 'superb-blocks');
493 }
494
495 // Validate base64 portion
496 $base64 = substr($value, strlen($prefix));
497 if (!preg_match('/^[A-Za-z0-9+\/=]+$/', $base64)) {
498 return __('Invalid signature data.', 'superb-blocks');
499 }
500
501 return '';
502 }
503
504 /**
505 * Validate slider: single numeric value within bounds.
506 */
507 private static function ValidateSlider($value, $config)
508 {
509 $ss = isset($config['sliderSettings']) && is_array($config['sliderSettings']) ? $config['sliderSettings'] : array();
510 $min = isset($ss['min']) ? floatval($ss['min']) : 0;
511 $max = isset($ss['max']) ? floatval($ss['max']) : 100;
512
513 if (!is_numeric($value)) {
514 return __('Please enter a valid number.', 'superb-blocks');
515 }
516
517 $num = floatval($value);
518 if ($num < $min || $num > $max) {
519 /* translators: 1: minimum allowed value, 2: maximum allowed value */
520 return sprintf(__('Value must be between %1$s and %2$s.', 'superb-blocks'), $min, $max);
521 }
522
523 return '';
524 }
525
526 /**
527 * Validate color: must be a valid hex color.
528 */
529 private static function ValidateColor($value)
530 {
531 if (!preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
532 return __('Please enter a valid hex color.', 'superb-blocks');
533 }
534
535 return '';
536 }
537
538 /**
539 * Validate rating: must be an integer between 0 and maxRating.
540 */
541 private static function ValidateRating($value, $config)
542 {
543 if (!is_numeric($value)) {
544 return __('Invalid rating.', 'superb-blocks');
545 }
546
547 $num = intval($value);
548 if ($num != floatval($value)) {
549 // Not an integer
550 return __('Invalid rating.', 'superb-blocks');
551 }
552
553 $rs = isset($config['ratingSettings']) && is_array($config['ratingSettings']) ? $config['ratingSettings'] : array();
554 $max_rating = isset($rs['maxRating']) ? intval($rs['maxRating']) : 5;
555
556 if ($num < 0 || $num > $max_rating) {
557 return __('Invalid rating.', 'superb-blocks');
558 }
559
560 return '';
561 }
562
563 // --- Helper methods ---
564
565 /**
566 * Check min/max length constraints.
567 */
568 private static function CheckLength($value, $config)
569 {
570 $len = function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
571
572 $min = isset($config['minLength']) ? $config['minLength'] : null;
573 $max = isset($config['maxLength']) ? $config['maxLength'] : null;
574
575 if ($min !== null && $min !== '' && $len < intval($min)) {
576 /* translators: %d: minimum required character count */
577 return sprintf(__('Minimum length is %d characters.', 'superb-blocks'), $min);
578 }
579
580 if ($max !== null && $max !== '' && $len > intval($max)) {
581 /* translators: %d: maximum allowed character count */
582 return sprintf(__('Maximum length is %d characters.', 'superb-blocks'), $max);
583 }
584
585 return '';
586 }
587
588 /**
589 * Check regex pattern constraint.
590 */
591 private static function CheckPattern($value, $config)
592 {
593 $pattern = isset($config['pattern']) && $config['pattern'] !== '' ? $config['pattern'] : null;
594 if ($pattern === null) {
595 return '';
596 }
597
598 // Wrap pattern in delimiters for PCRE, using '/' with escaping
599 $regex = '/' . str_replace('/', '\\/', $pattern) . '/';
600
601 // Guard against ReDoS from user-supplied patterns by tightening PCRE's backtrack limit around this single preg_match call. ini_set is intentional: there is no WP-API equivalent for runtime PCRE limits, the override is reverted immediately after, and the limit is per-process rather than persistent.
602 $old_limit = ini_get('pcre.backtrack_limit');
603 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
604 ini_set('pcre.backtrack_limit', 10000);
605 $result = @preg_match($regex, $value);
606 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
607 ini_set('pcre.backtrack_limit', $old_limit);
608
609 // If regex is invalid ($result === false) or doesn't match ($result === 0)
610 if ($result === 0) {
611 $msg = isset($config['patternMessage']) && $config['patternMessage'] !== ''
612 ? $config['patternMessage']
613 : __('Invalid format.', 'superb-blocks');
614 return $msg;
615 }
616
617 // Invalid regex or match: pass
618 return '';
619 }
620
621 /**
622 * Resolve the error message for an empty required field.
623 * Field-level override wins, then the form-wide default, then the localized fallback.
624 * Also used by FormFileHandler so file fields resolve identically.
625 *
626 * @param array $config Field config array
627 * @param string $default_required_message Form-wide default, '' when not set
628 * @return string
629 */
630 public static function GetRequiredMessage($config, $default_required_message = '')
631 {
632 if (isset($config['requiredMessage']) && is_string($config['requiredMessage']) && $config['requiredMessage'] !== '') {
633 return sanitize_text_field($config['requiredMessage']);
634 }
635 if (is_string($default_required_message) && $default_required_message !== '') {
636 return $default_required_message;
637 }
638 return __('This field is required.', 'superb-blocks');
639 }
640
641 /**
642 * Check if a field has active conditional logic rules.
643 */
644 private static function HasConditionalLogic($config)
645 {
646 if (!isset($config['conditionalLogic']) || !is_array($config['conditionalLogic'])) {
647 return false;
648 }
649
650 $logic = $config['conditionalLogic'];
651 if (!isset($logic['ruleGroups']) || !is_array($logic['ruleGroups'])) {
652 return false;
653 }
654
655 foreach ($logic['ruleGroups'] as $group) {
656 if (isset($group['conditions']) && is_array($group['conditions'])) {
657 foreach ($group['conditions'] as $cond) {
658 if (!empty($cond['field'])) {
659 return true;
660 }
661 }
662 }
663 }
664
665 return false;
666 }
667 }
668