From 5ac82c24145bb806023f71b4c1fc7bac082553d3 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 7 Nov 2026 15:52:24 +0100 Subject: [PATCH] feat(conditional): allow branches on short and long text questions A text question opens a branch when the answer equals (short text only) or contains a text, or matches a regular expression. Regular expressions use the same /pattern/modifiers format as validationRegex, are checked when saving, and an invalid one is not saved by the editor. Matching is limited in subject length and backtracking. The code comes from the original pull request #3123 by Micke Nordin and TimedIn, with the review fixes applied. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- docs/DataStructure.md | 22 +++++ lib/Constants.php | 5 + lib/Service/FormsService.php | 3 + lib/Service/SubmissionService.php | 56 +++++++++++ .../Conditional/BranchConditionEditor.vue | 95 +++++++++++++++++++ src/components/Questions/QuestionLong.vue | 1 + src/components/Questions/QuestionShort.vue | 1 + src/utils/Branches.ts | 52 ++++++++++ tests/Unit/Service/FormsServiceTest.php | 25 +++++ tests/Unit/Service/SubmissionServiceTest.php | 47 +++++++++ 10 files changed, 307 insertions(+) diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 02ef5aa73..2dc1d2749 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -304,6 +304,8 @@ Follow-up questions (subquestions) are questions of their own with additional pr | `multiple_unique` | `option_selected` | Radio buttons - the selected option | | `dropdown` | `option_selected` | Dropdown - the selected option | | `multiple` | `options_combination` | Checkboxes - all options of a combination | +| `short` | `string_equals`, `string_contains`, `regex` | Short text - string or regex matching | +| `long` | `string_contains`, `regex` | Long text - string or regex matching | | `linearscale` | `value_equals`, `value_not_equals`, `value_range`, `value_min`, `value_max` | Linear scale - numeric value matching | | `date` | `date_range` | Date - range including its bounds (`YYYY-MM-DD`) | | `datetime` | `date_range` | Deprecated date and time (`YYYY-MM-DD HH:mm`) | @@ -359,6 +361,26 @@ Conditions determine when a branch is activated. The structure depends on the ty All options in `optionIds` must be selected for the condition to match. +#### string_equals (for short) + +```json +{ "type": "string_equals", "value": "yes" } +``` + +#### string_contains (for short, long) + +```json +{ "type": "string_contains", "value": "keyword" } +``` + +#### regex (for short, long) + +```json +{ "type": "regex", "value": "/^yes/i" } +``` + +The expression uses the same format as `validationRegex`: enclosed in slashes, with the optional modifiers `i`, `m` and `s`. + #### value_equals, value_not_equals (for linearscale), value_equals (for color) ```json diff --git a/lib/Constants.php b/lib/Constants.php index d69b2dd73..377b75031 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -244,6 +244,9 @@ class Constants { */ public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected'; public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination'; + public const CONDITION_TYPE_STRING_EQUALS = 'string_equals'; + public const CONDITION_TYPE_STRING_CONTAINS = 'string_contains'; + public const CONDITION_TYPE_REGEX = 'regex'; public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals'; public const CONDITION_TYPE_VALUE_NOT_EQUALS = 'value_not_equals'; public const CONDITION_TYPE_VALUE_RANGE = 'value_range'; @@ -262,6 +265,8 @@ class Constants { self::ANSWER_TYPE_MULTIPLEUNIQUE => [self::CONDITION_TYPE_OPTION_SELECTED], self::ANSWER_TYPE_DROPDOWN => [self::CONDITION_TYPE_OPTION_SELECTED], self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION], + self::ANSWER_TYPE_SHORT => [self::CONDITION_TYPE_STRING_EQUALS, self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], + self::ANSWER_TYPE_LONG => [self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS, self::CONDITION_TYPE_VALUE_NOT_EQUALS, self::CONDITION_TYPE_VALUE_RANGE, self::CONDITION_TYPE_VALUE_MIN, self::CONDITION_TYPE_VALUE_MAX], self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE], self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE], diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index a854bb784..806633ea4 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -993,6 +993,9 @@ private function areBranchConditionsValid(string $questionType, array $condition return false; } } + if (($condition['type'] ?? null) === Constants::CONDITION_TYPE_REGEX && !$this->isValidRegex((string)($condition['value'] ?? ''))) { + return false; + } } return true; } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index b0d868166..3827040aa 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -1077,6 +1077,33 @@ private function evaluateBranchConditions(string $triggerType, array $triggerAns } } return false; + case Constants::ANSWER_TYPE_SHORT: + case Constants::ANSWER_TYPE_LONG: + // Text-based: evaluate regex/string conditions + $text = is_scalar($triggerAnswer[0] ?? null) ? (string)$triggerAnswer[0] : ''; + foreach ($conditions as $condition) { + $type = $condition['type'] ?? Constants::CONDITION_TYPE_STRING_CONTAINS; + $value = $condition['value'] ?? ''; + + switch ($type) { + case Constants::CONDITION_TYPE_STRING_EQUALS: + if ($text === $value) { + return true; + } + break; + case Constants::CONDITION_TYPE_STRING_CONTAINS: + if (str_contains($text, $value)) { + return true; + } + break; + case Constants::CONDITION_TYPE_REGEX: + if ($this->safeRegexMatch($value, $text)) { + return true; + } + break; + } + } + return false; case Constants::ANSWER_TYPE_LINEARSCALE: $numValue = (float)($triggerAnswer[0] ?? 0); foreach ($conditions as $condition) { @@ -1158,4 +1185,33 @@ private function evaluateBranchConditions(string $triggerType, array $triggerAns return false; } } + + /** + * Safely execute a regex match with validation to prevent ReDoS attacks + * + * @param string $pattern The regex pattern to match + * @param string $subject The string to match against + * @return bool True if the pattern matches, false otherwise + */ + private function safeRegexMatch(string $pattern, string $subject): bool { + if (empty($pattern) || strlen($subject) > 10000) { + return false; + } + + // Validate regex syntax + if (@preg_match($pattern, '') === false) { + return false; + } + + // Set backtrack limit to prevent catastrophic backtracking + $previousLimit = ini_get('pcre.backtrack_limit'); + ini_set('pcre.backtrack_limit', '10000'); + + try { + $result = @preg_match($pattern, $subject); + return $result === 1; + } finally { + ini_set('pcre.backtrack_limit', $previousLimit); + } + } } diff --git a/src/components/Conditional/BranchConditionEditor.vue b/src/components/Conditional/BranchConditionEditor.vue index e6ea37aa5..255f7f7e6 100644 --- a/src/components/Conditional/BranchConditionEditor.vue +++ b/src/components/Conditional/BranchConditionEditor.vue @@ -32,6 +32,33 @@ + + +