From 7ca851144bde8514f480ea5cf6a3446ed083d035 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 4 Nov 2026 12:51:08 +0100 Subject: [PATCH] feat(conditional): add short and long text as trigger A text trigger activates 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 +++++++++++++++++++ .../Questions/QuestionConditional.vue | 44 +++++++++ tests/Unit/Service/FormsServiceTest.php | 32 ++++++- tests/Unit/Service/SubmissionServiceTest.php | 69 ++++++++++++++ 8 files changed, 325 insertions(+), 1 deletion(-) diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 9d29f8cae..e339d9322 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -308,6 +308,8 @@ Supported trigger types for conditional questions: | `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`) | @@ -363,6 +365,26 @@ Conditions determine when a branch is activated. The structure depends on the tr 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 31690f596..aabae35bf 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -261,6 +261,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'; @@ -277,6 +280,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 863aa132c..1e9ade3e2 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -965,6 +965,9 @@ private function areBranchConditionsValid(string $triggerType, array $conditions 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 4f0b97618..f7f45436d 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -1159,6 +1159,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) { @@ -1240,4 +1267,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 @@ + + +