@@ -147,6 +174,7 @@ import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
+import { validateExpression } from '../../utils/RegularExpression.ts'
export default {
name: 'BranchConditionEditor',
@@ -188,6 +216,13 @@ export default {
emits: ['update:branch'],
+ data() {
+ return {
+ // Text being typed, an invalid regular expression is not saved
+ textDraft: null,
+ }
+ },
+
computed: {
/**
* Check if trigger is option-based (radio, dropdown, checkbox)
@@ -205,6 +240,13 @@ export default {
return ['multiple_unique', 'dropdown'].includes(this.triggerType)
},
+ /**
+ * Check if trigger is text-based (short, long)
+ */
+ isTextBasedTrigger() {
+ return ['short', 'long'].includes(this.triggerType)
+ },
+
/**
* Check if trigger is value-based (linearscale, color)
*/
@@ -262,6 +304,22 @@ export default {
return this.isSingleSelect ? selected[0] || null : selected
},
+ /**
+ * Text condition type options
+ */
+ textConditionTypes() {
+ const types = [
+ { value: 'string_equals', label: t('forms', 'Equals') },
+ { value: 'string_contains', label: t('forms', 'Contains') },
+ { value: 'regex', label: t('forms', 'Matches regex') },
+ ]
+ // Long text doesn't support string_equals
+ if (this.triggerType === 'long') {
+ return types.filter((t) => t.value !== 'string_equals')
+ }
+ return types
+ },
+
/**
* Value condition type options (for linear scale)
*/
@@ -292,6 +350,7 @@ export default {
* Default condition type based on trigger
*/
defaultConditionType() {
+ if (this.isTextBasedTrigger) return 'string_contains'
if (this.triggerType === 'linearscale') return 'value_equals'
if (this.isDateBasedTrigger) return 'date_range'
return null
@@ -310,6 +369,29 @@ export default {
},
},
+ /**
+ * Value of a text condition, regular expressions are only saved when valid
+ */
+ textConditionValue: {
+ get() {
+ return this.textDraft ?? this.conditionValue
+ },
+
+ set(value) {
+ this.textDraft = value
+ if (this.conditionType !== 'regex' || validateExpression(value)) {
+ this.conditionValue = value
+ }
+ },
+ },
+
+ isRegexInvalid() {
+ return (
+ this.conditionType === 'regex'
+ && !validateExpression(this.textConditionValue)
+ )
+ },
+
/**
* Storage format of date and time conditions, the same as for answers
*/
@@ -370,6 +452,19 @@ export default {
return this.branch.conditions?.[0]?.fileUploaded ?? true
},
+ /**
+ * Placeholder for condition value input
+ */
+ conditionValuePlaceholder() {
+ if (this.conditionType === 'regex') {
+ return t('forms', 'Regular expression, e.g. /^yes$/i')
+ }
+ if (this.conditionType === 'string_equals') {
+ return t('forms', 'Exact text to match')
+ }
+ return t('forms', 'Text to search for')
+ },
+
/**
* Date picker type based on trigger type
*/
diff --git a/src/components/Questions/QuestionConditional.vue b/src/components/Questions/QuestionConditional.vue
index c45af8d72..98fcbb241 100644
--- a/src/components/Questions/QuestionConditional.vue
+++ b/src/components/Questions/QuestionConditional.vue
@@ -345,6 +345,7 @@ import {
import answerTypes from '../../models/AnswerTypes.ts'
import logger from '../../utils/Logger.ts'
import OcsResponse2Data from '../../utils/OcsResponse2Data.ts'
+import { splitRegex } from '../../utils/RegularExpression.ts'
// Types of subquestions, conditional questions can not be nested
const SUBQUESTION_TYPES = [
@@ -411,6 +412,10 @@ export default defineComponent({
label: t('forms', 'Choice'),
types: ['multiple_unique', 'multiple', 'dropdown'],
},
+ {
+ label: t('forms', 'Text'),
+ types: ['short', 'long'],
+ },
{
label: t('forms', 'Date and time'),
types: ['date', 'time'],
@@ -975,6 +980,22 @@ export default defineComponent({
return false
}
+ if (['short', 'long'].includes(this.triggerType)) {
+ const text = this.triggerValues[0] || ''
+ return branch.conditions.some((c) => {
+ if (c.type === 'regex') {
+ return this.safeRegexMatch(c.value, text)
+ }
+ if (c.type === 'string_contains') {
+ return text.includes(c.value || '')
+ }
+ if (c.type === 'string_equals') {
+ return text === c.value
+ }
+ return false
+ })
+ }
+
if (this.triggerType === 'linearscale') {
const numValue = parseFloat(this.triggerValues[0]) || 0
return branch.conditions.some((c) => {
@@ -1033,6 +1054,29 @@ export default defineComponent({
return false
},
+ /**
+ * Safely execute a regex match to prevent ReDoS attacks
+ *
+ * @param {string} pattern The regular expression with delimiters, e.g. `/^yes$/i`
+ * @param {string} subject The string to match against
+ * @return {boolean} True if pattern matches
+ */
+ safeRegexMatch(pattern, subject) {
+ if (!pattern || subject.length > 10000) {
+ return false
+ }
+ // Same format as the server: `/pattern/modifiers`
+ const { pattern: source, modifiers } = splitRegex(pattern)
+ if (!source) {
+ return false
+ }
+ try {
+ return new RegExp(source, modifiers).test(subject)
+ } catch {
+ return false
+ }
+ },
+
async validate() {
if (this.$refs.triggerQuestion?.validate) {
const triggerValid = await this.$refs.triggerQuestion.validate()
diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php
index d23de6533..1ce96792f 100644
--- a/tests/Unit/Service/FormsServiceTest.php
+++ b/tests/Unit/Service/FormsServiceTest.php
@@ -1936,6 +1936,16 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => true
],
+ 'valid-conditional-short-trigger' => [
+ 'extraSettings' => [
+ 'triggerType' => Constants::ANSWER_TYPE_SHORT,
+ 'branches' => [
+ ['id' => 'branch-1', 'conditions' => [['type' => 'string_equals', 'value' => 'yes']]]
+ ]
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
+ 'expected' => true
+ ],
'valid-conditional-linearscale-trigger' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_LINEARSCALE,
@@ -2051,9 +2061,29 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => false
],
+ 'valid-conditional-regex-condition' => [
+ 'extraSettings' => [
+ 'triggerType' => Constants::ANSWER_TYPE_SHORT,
+ 'branches' => [
+ ['id' => 'branch-1', 'conditions' => [['type' => 'regex', 'value' => '/^ye(s|p)$/i']]]
+ ]
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
+ 'expected' => true
+ ],
+ 'invalid-conditional-regex-without-delimiters' => [
+ 'extraSettings' => [
+ 'triggerType' => Constants::ANSWER_TYPE_SHORT,
+ 'branches' => [
+ ['id' => 'branch-1', 'conditions' => [['type' => 'regex', 'value' => '^yes$']]]
+ ]
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
+ 'expected' => false
+ ],
'invalid-conditional-condition-type-of-other-trigger' => [
'extraSettings' => [
- 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN,
+ 'triggerType' => Constants::ANSWER_TYPE_LONG,
'branches' => [
['id' => 'branch-1', 'conditions' => [['type' => 'value_range', 'min' => 1, 'max' => 3]]]
]
diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php
index 6363b3061..56836f527 100644
--- a/tests/Unit/Service/SubmissionServiceTest.php
+++ b/tests/Unit/Service/SubmissionServiceTest.php
@@ -1665,6 +1665,50 @@ public static function dataValidateSubmission() {
// Expected Result - valid
null,
],
+ 'valid-conditional-short-text-trigger' => [
+ // Questions - conditional with short text trigger using string_equals
+ [
+ [
+ 'id' => 100,
+ 'type' => Constants::ANSWER_TYPE_CONDITIONAL,
+ 'text' => 'Text Conditional',
+ 'isRequired' => true,
+ 'extraSettings' => [
+ 'triggerType' => Constants::ANSWER_TYPE_SHORT,
+ 'branches' => [
+ [
+ 'id' => 'branch-yes',
+ 'conditions' => [['type' => 'string_equals', 'value' => 'yes']],
+ 'subQuestions' => [
+ [
+ 'id' => 101,
+ 'type' => 'short',
+ 'text' => 'Please elaborate',
+ 'isRequired' => true,
+ ]
+ ]
+ ],
+ [
+ 'id' => 'branch-no',
+ 'conditions' => [['type' => 'string_equals', 'value' => 'no']],
+ 'subQuestions' => []
+ ]
+ ]
+ ]
+ ]
+ ],
+ // Answers - trigger "yes" activates branch-yes
+ [
+ '100' => [
+ 'trigger' => ['yes'],
+ 'subQuestions' => [
+ '101' => ['More details here']
+ ]
+ ]
+ ],
+ // Expected Result - valid
+ null,
+ ],
'valid-conditional-checkbox-combination-trigger' => [
// Questions - conditional with multiple (checkbox) trigger
[
@@ -1906,6 +1950,31 @@ public static function dataValidateSubmission() {
// Expected Result
'Invalid answer for conditional question.',
],
+ 'invalid-conditional-regex-trigger' => [
+ // Questions - regex conditions use the same delimited format as validationRegex
+ [
+ [
+ 'id' => 100,
+ 'type' => Constants::ANSWER_TYPE_CONDITIONAL,
+ 'text' => 'Text Conditional',
+ 'isRequired' => false,
+ 'extraSettings' => [
+ 'triggerType' => Constants::ANSWER_TYPE_SHORT,
+ 'branches' => [
+ [
+ 'id' => 'branch-yes',
+ 'conditions' => [['type' => 'regex', 'value' => '/^ye(s|p)$/i']],
+ 'subQuestions' => [['id' => 101, 'type' => 'short', 'text' => 'Please elaborate', 'isRequired' => true]],
+ ],
+ ],
+ ],
+ ],
+ ],
+ // Answers - matches the regex, required subquestion not answered
+ ['100' => ['trigger' => ['Yep'], 'subQuestions' => []]],
+ // Expected Result
+ 'Question "Please elaborate" is required.',
+ ],
'invalid-conditional-date-range-includes-its-bounds' => [
// Questions - date range of a single day
[