PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form / class-conditional-logic.php

class-conditional-logic.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-forms/src/contact-form/class-conditional-logic.php

631 lines 20.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Conditional Logic evaluator for Jetpack form fields.
4 *
5 * Mirrors the JS implementation in
6 * src/blocks/shared/conditional-logic/util/evaluate.ts and MUST stay in sync.
7 *
8 * The browser decides what a visitor sees; this class decides what gets validated and
9 * stored. A disagreement between the two either discards a real answer or accepts one for
10 * a field that was never shown, so Conditional_Logic_Parity_Test pins the operator
11 * vocabulary and Conditional_Logic_Test mirrors the JS cases one for one.
12 *
13 * Targets PHP 7.4: no typed class constants, no match expressions.
14 *
15 * @package automattic/jetpack-forms
16 */
17
18 namespace Automattic\Jetpack\Forms\ContactForm;
19
20 /**
21 * Pure evaluator for field conditional-logic rules.
22 */
23 class Conditional_Logic {
24
25 const OP_IS = 'is';
26 const OP_IS_NOT = 'is_not';
27 const OP_CONTAINS = 'contains';
28 const OP_DOES_NOT_CONTAIN = 'does_not_contain';
29 const OP_IS_EMPTY = 'is_empty';
30 const OP_IS_NOT_EMPTY = 'is_not_empty';
31 const OP_EQUALS = 'equals';
32 const OP_NOT_EQUALS = 'not_equals';
33 const OP_GREATER_THAN = 'greater_than';
34 const OP_LESS_THAN = 'less_than';
35 const OP_GTE = 'gte';
36 const OP_LTE = 'lte';
37 const OP_BEFORE = 'before';
38 const OP_AFTER = 'after';
39 const OP_IS_CHECKED = 'is_checked';
40 const OP_IS_NOT_CHECKED = 'is_not_checked';
41
42 /**
43 * The rule type this release understands.
44 *
45 * Rules carry their own type so further condition kinds -- query string, user role, date
46 * and time -- become new rule types inside the existing groups rather than another
47 * reshape of the stored attribute.
48 */
49 const RULE_TYPE_FIELD_VALUE = 'fieldValue';
50
51 /**
52 * Shortcode field type to comparison behavior.
53 *
54 * Mirrors TYPE_KEY_BY_FIELD_TYPE in
55 * src/blocks/shared/conditional-logic/util/field-types.ts. `field-telephone` renders as
56 * either `telephone` or `phone` depending on its country-selector setting, so both appear.
57 *
58 * @var array
59 */
60 const TYPE_KEY_BY_FIELD_TYPE = array(
61 'text' => 'string',
62 'name' => 'string',
63 'email' => 'string',
64 'url' => 'string',
65 'textarea' => 'string',
66 'telephone' => 'string',
67 'phone' => 'string',
68 'select' => 'choice',
69 'radio' => 'choice',
70 'image-select' => 'choice',
71 'checkbox-multiple' => 'multichoice',
72 'number' => 'number',
73 'slider' => 'number',
74 'rating' => 'rating',
75 'date' => 'date',
76 'time' => 'time',
77 'checkbox' => 'boolean',
78 'consent' => 'boolean',
79 'hidden' => 'hidden',
80 'file' => 'file',
81 );
82
83 /**
84 * Operators that compare against nothing, so `value` is ignored.
85 *
86 * @var array
87 */
88 const OPERATORS_WITHOUT_VALUE = array(
89 self::OP_IS_EMPTY,
90 self::OP_IS_NOT_EMPTY,
91 self::OP_IS_CHECKED,
92 self::OP_IS_NOT_CHECKED,
93 );
94
95 /**
96 * Resolve a shortcode field type to its comparison behavior.
97 *
98 * @param string $field_type The shortcode `type` attribute.
99 *
100 * @return string The type key; unknown types compare textually rather than being dropped.
101 */
102 public static function type_key_for_field_type( $field_type ): string {
103 if ( ! is_string( $field_type ) || '' === $field_type ) {
104 return 'string';
105 }
106
107 return self::TYPE_KEY_BY_FIELD_TYPE[ $field_type ] ?? 'string';
108 }
109
110 /**
111 * Evaluate a field's conditional logic.
112 *
113 * A rule whose subject field is absent from `$field_types` is ignored rather than
114 * compared against an empty value, so deleting an unrelated block cannot silently hide a
115 * field. When every rule is ignored the field stays visible.
116 *
117 * @param array|null $logic The field's conditional-logic config.
118 * @param array $field_types Map of field id to shortcode type, for every form field.
119 * @param array $form_values Map of field id to submitted value.
120 * @param array $field_formats Map of field id to date format, for date fields.
121 *
122 * @return bool True when the field should be visible.
123 */
124 public static function evaluate( $logic, array $field_types, array $form_values, array $field_formats = array() ): bool {
125 if ( ! is_array( $logic ) || empty( $logic['enabled'] ) ) {
126 return true;
127 }
128
129 $groups = isset( $logic['groups'] ) && is_array( $logic['groups'] ) ? $logic['groups'] : array();
130
131 if ( empty( $groups ) ) {
132 return true;
133 }
134
135 // Each group reduces its own rules with its own operator; the groups then reduce with
136 // the top-level one. With a single group -- all the V1 panel writes -- the outer
137 // reduction is a no-op, so this behaves exactly as a flat rule list until a second
138 // group exists.
139 $group_outcomes = array();
140
141 foreach ( $groups as $group ) {
142 $rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array();
143
144 $outcomes = array();
145 foreach ( $rules as $rule ) {
146 if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) {
147 continue;
148 }
149
150 // A condition kind this release does not know: ignore that rule, so a form
151 // saved by a newer editor degrades to its remaining conditions.
152 if ( ! empty( $rule['type'] ) && self::RULE_TYPE_FIELD_VALUE !== $rule['type'] ) {
153 continue;
154 }
155
156 $field_id = (string) $rule['field'];
157 if ( ! array_key_exists( $field_id, $field_types ) ) {
158 continue; // Subject field no longer exists: ignore this rule.
159 }
160
161 $type_key = self::type_key_for_field_type( $field_types[ $field_id ] );
162 $actual = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
163 // A date field's value is written in its own format, so the comparison needs it.
164 $format = isset( $field_formats[ $field_id ] ) ? (string) $field_formats[ $field_id ] : '';
165 $outcome = self::evaluate_rule_value( $rule, $type_key, $actual, $format );
166
167 if ( null !== $outcome ) {
168 $outcomes[] = $outcome;
169 }
170 }
171
172 // A group with nothing evaluable is ignored, the same way a single unusable rule
173 // is, so deleting a subject field cannot silently hide the field referencing it.
174 if ( empty( $outcomes ) ) {
175 continue;
176 }
177
178 $group_operator = $group['logicalOperator'] ?? 'any';
179 $group_outcomes[] = 'all' === $group_operator
180 ? ! in_array( false, $outcomes, true )
181 : in_array( true, $outcomes, true );
182 }
183
184 if ( empty( $group_outcomes ) ) {
185 return true;
186 }
187
188 $logical_operator = $logic['logicalOperator'] ?? 'any';
189
190 if ( 'all' === $logical_operator ) {
191 $matched = ! in_array( false, $group_outcomes, true );
192 } else {
193 $matched = in_array( true, $group_outcomes, true );
194 }
195
196 $action = $logic['action'] ?? 'show';
197
198 return 'hide' === $action ? ! $matched : $matched;
199 }
200
201 /**
202 * Resolve visibility for every field in a form at once.
203 *
204 * Runs to a fixed point so a hidden field's value reads as empty for everyone else: if the
205 * question was never asked, its answer must not satisfy another field's condition. On
206 * ambiguity — circular rules, or passes exhausted — the field is left visible, because a
207 * stray value in a response is recoverable and a silently discarded answer is not.
208 *
209 * @param array $fields Map of field id to `array( 'logic' => array|null, 'type' => string )`.
210 * @param array $form_values Map of field id to submitted value.
211 *
212 * @return array Map of field id to bool visibility.
213 */
214 public static function resolve_visibility( array $fields, array $form_values ): array {
215 $visible = array();
216 foreach ( $fields as $field_id => $descriptor ) {
217 $visible[ $field_id ] = true;
218 }
219
220 $with_logic = array();
221 $field_types = array();
222 $field_formats = array();
223 foreach ( $fields as $field_id => $descriptor ) {
224 $field_types[ $field_id ] = $descriptor['type'] ?? 'text';
225 if ( isset( $descriptor['format'] ) ) {
226 $field_formats[ $field_id ] = (string) $descriptor['format'];
227 }
228 if ( isset( $descriptor['logic'] ) && is_array( $descriptor['logic'] ) && ! empty( $descriptor['logic']['enabled'] ) ) {
229 $with_logic[] = $field_id;
230 }
231 }
232
233 if ( empty( $with_logic ) ) {
234 return $visible;
235 }
236
237 // An acyclic dependency chain settles at least one more level per pass, so one pass
238 // per conditional field, plus one to confirm nothing moved, always reaches the fixed
239 // point unless the rules are circular.
240 //
241 // This used to be clamped to a constant 25, which made that claim false: a chain
242 // deeper than 24 ran out of passes, was read as circular, and failed open with
243 // fields left visible that should have been hidden. The bound is the field count
244 // because that is what actually guarantees convergence -- and it is self-limiting,
245 // since it can only be as large as the form.
246 $max_passes = count( $with_logic ) + 1;
247
248 // Fields that change after the opening pass are reacting to another field's change,
249 // which is the signature of an oscillation. Collected across every pass, because a
250 // participant in a cycle need not be the one that flipped on the final pass.
251 $unstable = array();
252
253 for ( $pass = 0; $pass < $max_passes; $pass++ ) {
254 $effective = array();
255 foreach ( $fields as $field_id => $descriptor ) {
256 $value = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
257 $effective[ $field_id ] = $visible[ $field_id ] ? $value : '';
258 }
259
260 $changed_count = 0;
261 foreach ( $with_logic as $field_id ) {
262 $next = self::evaluate( $fields[ $field_id ]['logic'], $field_types, $effective, $field_formats );
263 if ( $next !== $visible[ $field_id ] ) {
264 $visible[ $field_id ] = $next;
265 ++$changed_count;
266 if ( $pass > 0 ) {
267 $unstable[ $field_id ] = true;
268 }
269 }
270 }
271
272 if ( 0 === $changed_count ) {
273 return $visible; // Fixed point.
274 }
275 }
276
277 // Passes exhausted, so the rules are circular. Fail open for everything in the cycle.
278 foreach ( array_keys( $unstable ) as $field_id ) {
279 $visible[ $field_id ] = true;
280 }
281
282 return $visible;
283 }
284
285 /**
286 * Evaluate a single rule against a submitted value.
287 *
288 * @param array $rule The rule.
289 * @param string $type_key Comparison behavior of the rule's subject field.
290 * @param mixed $actual The subject field's current value.
291 * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
292 *
293 * @return bool|null True or false, or null when the rule must be ignored.
294 */
295 private static function evaluate_rule_value( array $rule, $type_key, $actual, $format = '' ) {
296 $operator = (string) $rule['operator'];
297 $needs_value = ! in_array( $operator, self::OPERATORS_WITHOUT_VALUE, true );
298 $expected = '';
299 if ( $needs_value && isset( $rule['value'] ) ) {
300 $expected = $rule['value'];
301 }
302
303 // An operator that compares against something, given nothing to compare against, cannot
304 // say anything -- so the rule is ignored rather than evaluated against an empty string.
305 // Evaluating it would be worse than useless: `does_not_contain ''` is true of every
306 // value, so a half-written rule would quietly force its field visible. The editor
307 // already tells the author this rule is inert; this is what makes that true.
308 if ( $needs_value && '' === trim( self::to_comparable_string( $expected ) ) ) {
309 return null;
310 }
311
312 switch ( $operator ) {
313 case self::OP_IS_EMPTY:
314 return self::is_empty_value( $actual );
315 case self::OP_IS_NOT_EMPTY:
316 return ! self::is_empty_value( $actual );
317 case self::OP_IS_CHECKED:
318 return ! self::is_empty_value( $actual );
319 case self::OP_IS_NOT_CHECKED:
320 return self::is_empty_value( $actual );
321 }
322
323 if ( 'multichoice' === $type_key ) {
324 $selection = self::to_selection_list( $actual );
325 $target = trim( self::to_comparable_string( $expected ) );
326
327 switch ( $operator ) {
328 case self::OP_CONTAINS:
329 return in_array( $target, $selection, true );
330 case self::OP_DOES_NOT_CONTAIN:
331 return ! in_array( $target, $selection, true );
332 }
333
334 return null;
335 }
336
337 if ( 'number' === $type_key || 'rating' === $type_key ) {
338 // A rating submits `selected/max`, e.g. `4/5`. The rule stores the bare number, so
339 // only the submitted side needs unpacking -- without it is_numeric() sees `4/5`,
340 // every comparison returns false, and a rating rule can never match.
341 $submitted = 'rating' === $type_key ? self::to_rating_value( $actual ) : $actual;
342 $pair = self::to_numeric_pair( $submitted, $expected );
343 if ( null === $pair ) {
344 return false;
345 }
346
347 switch ( $operator ) {
348 case self::OP_EQUALS:
349 return $pair[0] === $pair[1];
350 case self::OP_NOT_EQUALS:
351 return $pair[0] !== $pair[1];
352 case self::OP_GREATER_THAN:
353 return $pair[0] > $pair[1];
354 case self::OP_LESS_THAN:
355 return $pair[0] < $pair[1];
356 case self::OP_GTE:
357 return $pair[0] >= $pair[1];
358 case self::OP_LTE:
359 return $pair[0] <= $pair[1];
360 }
361
362 return null;
363 }
364
365 if ( 'date' === $type_key || 'time' === $type_key ) {
366 $pair = self::to_temporal_pair( $actual, $expected, $type_key, $format );
367 if ( null === $pair ) {
368 return false;
369 }
370
371 switch ( $operator ) {
372 case self::OP_IS:
373 return $pair[0] === $pair[1];
374 case self::OP_IS_NOT:
375 return $pair[0] !== $pair[1];
376 case self::OP_BEFORE:
377 return $pair[0] < $pair[1];
378 case self::OP_AFTER:
379 return $pair[0] > $pair[1];
380 }
381
382 return null;
383 }
384
385 // string, choice, hidden and file all compare textually.
386 $left = self::to_comparable_string( $actual );
387 $right = self::to_comparable_string( $expected );
388
389 switch ( $operator ) {
390 case self::OP_IS:
391 return $left === $right;
392 case self::OP_IS_NOT:
393 return $left !== $right;
394 case self::OP_CONTAINS:
395 return '' !== $right && false !== strpos( $left, $right );
396 case self::OP_DOES_NOT_CONTAIN:
397 return '' === $right || false === strpos( $left, $right );
398 }
399
400 return null;
401 }
402
403 /**
404 * Normalize a multi-select value into a list of selected option strings.
405 *
406 * Membership comparison exists so `contains "Blue"` does not match an option named
407 * "Blueberry", and so an option containing a comma cannot corrupt the comparison.
408 *
409 * @param mixed $value The submitted value.
410 *
411 * @return array Selected options, trimmed, with blanks removed.
412 */
413 private static function to_selection_list( $value ): array {
414 $raw = is_array( $value ) ? $value : array( $value );
415 $list = array();
416
417 foreach ( $raw as $item ) {
418 $text = trim( self::to_comparable_string( $item ) );
419 if ( '' !== $text ) {
420 $list[] = $text;
421 }
422 }
423
424 return $list;
425 }
426
427 /**
428 * The selected value of a rating, dropping the scale it was submitted with.
429 *
430 * @param mixed $value The submitted value, `selected/max` or a bare number.
431 *
432 * @return string The selected part, for numeric comparison.
433 */
434 private static function to_rating_value( $value ) {
435 $text = trim( self::to_comparable_string( $value ) );
436 $slash = strpos( $text, '/' );
437
438 return false === $slash ? $text : substr( $text, 0, $slash );
439 }
440
441 /**
442 * Parse both sides of a numeric comparison.
443 *
444 * @param mixed $actual The submitted value.
445 * @param mixed $expected The value configured on the rule.
446 *
447 * @return array|null Both sides as floats, or null when either side is not numeric.
448 */
449 private static function to_numeric_pair( $actual, $expected ) {
450 $left = trim( self::to_comparable_string( $actual ) );
451 $right = trim( self::to_comparable_string( $expected ) );
452
453 if ( '' === $left || '' === $right || ! is_numeric( $left ) || ! is_numeric( $right ) ) {
454 return null;
455 }
456
457 return array( (float) $left, (float) $right );
458 }
459
460 /**
461 * Parse both sides of a date or time comparison into comparable numbers.
462 *
463 * Times become minutes since midnight, so a bare `HH:MM` needs no date context.
464 *
465 * @param mixed $actual The submitted value.
466 * @param mixed $expected The value configured on the rule.
467 * @param string $type_key Either `date` or `time`.
468 * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
469 *
470 * @return array|null Both sides as ints, or null when either side cannot be parsed.
471 */
472 private static function to_temporal_pair( $actual, $expected, $type_key, $format = '' ) {
473 // The submitted value is written in the field's format; a rule's value is always ISO.
474 $left = self::parse_temporal( $actual, $type_key, $format );
475 $right = self::parse_temporal( $expected, $type_key, '' );
476
477 if ( null === $left || null === $right ) {
478 return null;
479 }
480
481 return array( $left, $right );
482 }
483
484 /**
485 * Parse one side of a temporal comparison.
486 *
487 * @param mixed $value The value to parse.
488 * @param string $type_key Either `date` or `time`.
489 * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
490 *
491 * @return int|null Comparable integer, or null when unparseable.
492 */
493 private static function parse_temporal( $value, $type_key, $format = '' ) {
494 $text = trim( self::to_comparable_string( $value ) );
495 if ( '' === $text ) {
496 return null;
497 }
498
499 if ( 'time' === $type_key ) {
500 if ( ! preg_match( '/^(\d{1,2}):(\d{2})/', $text, $matches ) ) {
501 return null;
502 }
503 return ( (int) $matches[1] ) * 60 + (int) $matches[2];
504 }
505
506 return self::parse_date( $text, $format );
507 }
508
509 /**
510 * Parse a date into a comparable YYYYMMDD integer.
511 *
512 * Deliberately not strtotime(). The browser has to reach the same answer, and its
513 * Date.parse() reads a bare `YYYY-MM-DD` as UTC while reading `mm/dd/yy` as local time --
514 * so for any visitor away from UTC the two engines disagreed by the offset, and `is` was
515 * false on one side while `after` was true on the other. A `dd/mm/yy` field was worse:
516 * neither engine could read `31/12/2026` at all, so a show-rule hid its field permanently
517 * and the answer was then dropped at storage.
518 *
519 * The field's own format decides how to read the value, the same way the datepicker
520 * writes it. A rule's value always arrives as ISO, since the rule builder uses a native
521 * date input, so ISO is accepted regardless of the field's format.
522 *
523 * @param string $text Date text.
524 * @param string $format Field date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
525 *
526 * @return int|null YYYYMMDD, or null when the text does not match.
527 */
528 private static function parse_date( $text, $format = '' ) {
529 // ISO first: the rule side is always ISO, and it is the default field format.
530 if ( preg_match( '/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m ) ) {
531 return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
532 }
533
534 if ( ! preg_match( '/^(\d{1,4})[\/.-](\d{1,2})[\/.-](\d{1,4})$/', $text, $m ) ) {
535 return null;
536 }
537
538 // jQuery UI tokens, as used by the field: `yy` is the four-digit year.
539 switch ( $format ) {
540 case 'dd/mm/yy':
541 return self::to_date_int( (int) $m[3], (int) $m[2], (int) $m[1] );
542 case 'mm/dd/yy':
543 return self::to_date_int( (int) $m[3], (int) $m[1], (int) $m[2] );
544 default:
545 return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
546 }
547 }
548
549 /**
550 * Combine date parts, rejecting anything out of range.
551 *
552 * @param int $year Four-digit year.
553 * @param int $month Month.
554 * @param int $day Day.
555 *
556 * @return int|null YYYYMMDD, or null when the parts cannot be a date.
557 */
558 private static function to_date_int( $year, $month, $day ) {
559 if ( $month < 1 || $month > 12 || $day < 1 || $day > 31 ) {
560 return null;
561 }
562
563 return $year * 10000 + $month * 100 + $day;
564 }
565
566 /**
567 * Reduce any submitted value to a comparable string.
568 *
569 * @param mixed $value The submitted value.
570 *
571 * @return string Comparable string; empty for values with no sensible text form.
572 */
573 private static function to_comparable_string( $value ): string {
574 if ( null === $value ) {
575 return '';
576 }
577 if ( is_bool( $value ) ) {
578 return $value ? '1' : '';
579 }
580 if ( is_array( $value ) ) {
581 $parts = array();
582 foreach ( $value as $item ) {
583 $parts[] = self::to_comparable_string( $item );
584 }
585 return implode( ',', $parts );
586 }
587 if ( is_object( $value ) ) {
588 return '';
589 }
590
591 return (string) $value;
592 }
593
594 /**
595 * Whether a submitted value counts as unanswered.
596 *
597 * @param mixed $value The submitted value.
598 *
599 * @return bool True when the field has no answer.
600 */
601 private static function is_empty_value( $value ): bool {
602 if ( null === $value ) {
603 return true;
604 }
605 if ( is_string( $value ) ) {
606 return '' === trim( $value );
607 }
608 if ( is_bool( $value ) ) {
609 return ! $value;
610 }
611 if ( is_array( $value ) ) {
612 foreach ( $value as $item ) {
613 if ( ! self::is_empty_value( $item ) ) {
614 return false;
615 }
616 }
617 return true;
618 }
619 if ( is_object( $value ) ) {
620 foreach ( get_object_vars( $value ) as $item ) {
621 if ( ! self::is_empty_value( $item ) ) {
622 return false;
623 }
624 }
625 return true;
626 }
627
628 return false;
629 }
630 }
631