PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
← All changes | app/Helpers/Helper.php +581 -79 6.2.26.2.14 View file →
@@ -44,19 +44,240 @@
44 44 $input = sanitize_text_field($input);
45 45 }
46 46 } elseif (is_array($input)) {
47 47 foreach ($input as $key => &$value) {
48 - $attribute = $attribute ? $attribute . '[' . $key . ']' : $key;
48 + // Local var: mutating $attribute here would collapse every sibling
49 + // after the first onto a bare key, resolving nested inputs to the wrong element.
50 + $childAttribute = $attribute ? $attribute . '[' . $key . ']' : $key;
49 51
50 - $value = static::sanitizer($value, $attribute, $fields);
52 + $value = static::sanitizer($value, $childAttribute, $fields);
53 + }
54 + unset($value);
55 + }
51 56
52 - $attribute = null;
57 + return $input;
58 + }
59 +
60 + /**
61 + * Flatten a request value into a plain, printable string.
62 + *
63 + * Request values are string or array. Walks nested arrays so a crafted
64 + * param[][] cannot raise an "Array to string conversion" notice, and the
65 + * caller can escape the result in one pass instead of branching on shape.
66 + *
67 + * @param mixed $value
68 + *
69 + * @return string
70 + */
71 + public static function flattenRequestValue($value)
72 + {
73 + if (!is_array($value)) {
74 + return is_scalar($value) ? (string) $value : '';
75 + }
76 +
77 + $flat = [];
78 +
79 + array_walk_recursive($value, function ($item) use (&$flat) {
80 + if (is_scalar($item)) {
81 + $flat[] = (string) $item;
53 82 }
83 + });
84 +
85 + return implode(', ', $flat);
86 + }
87 +
88 + public static function isOptionGroup($option)
89 + {
90 + return is_array($option)
91 + && ArrayHelper::get($option, 'type') === 'group'
92 + && is_array(ArrayHelper::get($option, 'options'));
93 + }
94 +
95 + /*
96 + * Int or nothing. Persisting free text here would hand the one user class
97 + * this sanitizer exists to contain an arbitrary string in form_fields, for a
98 + * value with no PHP consumer at all — it only keys the editor's Vue list.
99 + * A non-numeric id collapses to 0, and ensureUniqueIds() regenerates it on
100 + * load. Numerics must stay numeric for the same reason: as the string "0" it
101 + * would be JS-truthy, and ensureUniqueIds() only regenerates ids that are
102 + * falsy or already seen.
103 + */
104 + public static function sanitizeOptionId($value)
105 + {
106 + return is_numeric($value) ? (int) $value : 0;
107 + }
108 +
109 + /*
110 + * sanitize_title, not sanitize_key: Pro writes inventory slugs with
111 + * sanitize_title (InventoryController), which percent-encodes non-ASCII.
112 + * sanitize_key strips '%', producing a slug that no longer exists — and a
113 + * truthy-but-missing slug resolves to quantity 0 in InventoryValidation, so
114 + * the option fails closed as permanently stocked out. sanitize_title is
115 + * idempotent over its own output and still neutralises markup.
116 + *
117 + * The is_scalar guard is ours: unlike sanitize_key, sanitize_title has no
118 + * guard of its own and raises a TypeError on PHP 8 for an array value.
119 + */
120 + public static function sanitizeOptionSlug($value)
121 + {
122 + return is_scalar($value) ? sanitize_title($value) : '';
123 + }
124 +
125 + /**
126 + * A key is unsafe when it opens a handler, or when it carries a character that
127 + * ENDS an attribute name in the HTML tokeniser -- whitespace, quote, slash,
128 + * equals or angle bracket. `esc_attr()` leaves those intact, so `x onclick`
129 + * renders as two attributes and the second one is live.
130 + *
131 + * Deny those characters rather than allow-list a charset: an allow-list also
132 + * rejects the legal-but-unusual keys real sites carry (leading underscore,
133 + * non-Latin names, framework prefixes) and silently drops working markup.
134 + *
135 + * @param string|int $key
136 + * @return bool
137 + */
138 + public static function isSafeAttributeKey($key)
139 + {
140 + $key = (string) $key;
141 +
142 + if ('' === $key || preg_match('/^on[a-z]/i', $key)) {
143 + return false;
54 144 }
55 145
56 - return $input;
146 + return !preg_match('/[\s"\'\/=<>`]|[\x00-\x1F\x7F]/', $key);
57 147 }
58 148
149 + /*
150 + * Keys the whitelist above drops but the editor and Pro Inventory need back.
151 + * They are sanitized rather than passed through, since preserving unknown
152 + * keys verbatim would defeat the whitelist for exactly the users this path
153 + * protects. Absent keys stay absent: an invented `quantity => 0` reads as
154 + * "stock out" to InventoryValidation.
155 + */
156 + protected static function optionPassthroughMap()
157 + {
158 + return [
159 + 'id' => [self::class, 'sanitizeOptionId'],
160 + 'quantity' => 'intval',
161 + 'global_inventory' => [self::class, 'sanitizeOptionSlug'],
162 + ];
163 + }
164 +
165 + public static function sanitizeAdvancedOptions($options, $depth = 0)
166 + {
167 + if (!is_array($options)) {
168 + return [];
169 + }
170 +
171 + $sanitized = [];
172 +
173 + foreach ($options as $option) {
174 + if (!is_array($option)) {
175 + continue;
176 + }
177 +
178 + if (self::isOptionGroup($option)) {
179 + $groupOptions = self::sanitizeAdvancedOptions(
180 + ArrayHelper::get($option, 'options', []),
181 + $depth + 1
182 + );
183 +
184 + if ($depth > 0) {
185 + $sanitized = array_merge($sanitized, $groupOptions);
186 + continue;
187 + }
188 +
189 + $groupLabel = ArrayHelper::get($option, 'label', '');
190 +
191 + $group = [
192 + 'type' => 'group',
193 + 'label' => wp_kses_post(is_scalar($groupLabel) ? $groupLabel : ''),
194 + 'options' => $groupOptions,
195 + ];
196 +
197 + // Groups are keyed on group.id in the editor exactly like leaf
198 + // options, and is_open is user-visible collapse state.
199 + if (array_key_exists('id', $option)) {
200 + $group['id'] = self::sanitizeOptionId($option['id']);
201 + }
202 + if (array_key_exists('is_open', $option)) {
203 + $group['is_open'] = (bool) $option['is_open'];
204 + }
205 +
206 + $sanitized[] = $group;
207 + continue;
208 + }
209 +
210 + /*
211 + * Non-scalars are flattened to '' before the WP sanitisers see them:
212 + * wp_kses_post() and sanitize_url() have no guard of their own and
213 + * raise a TypeError on PHP 8 for an array, which would be an uncaught
214 + * 500 on save for exactly the users this whitelist protects.
215 + */
216 + $scalar = function ($key) use ($option) {
217 + $value = ArrayHelper::get($option, $key, '');
218 +
219 + return is_scalar($value) ? $value : '';
220 + };
221 +
222 + $clean = [
223 + 'label' => wp_kses_post($scalar('label')),
224 + 'value' => sanitize_text_field($scalar('value')),
225 + 'image' => sanitize_url($scalar('image')),
226 + 'calc_value' => sanitize_text_field($scalar('calc_value')),
227 + 'disabled' => ArrayHelper::isTrue($option, 'disabled'),
228 + ];
229 +
230 + foreach (self::optionPassthroughMap() as $key => $sanitizer) {
231 + if (array_key_exists($key, $option)) {
232 + $clean[$key] = call_user_func($sanitizer, $option[$key]);
233 + }
234 + }
235 +
236 + $sanitized[] = $clean;
237 + }
238 +
239 + return $sanitized;
240 + }
241 +
242 + public static function flattenAdvancedOptions($options)
243 + {
244 + if (!is_array($options)) {
245 + return [];
246 + }
247 +
248 + $flattened = [];
249 +
250 + foreach ($options as $option) {
251 + if (self::isOptionGroup($option)) {
252 + $flattened = array_merge(
253 + $flattened,
254 + self::flattenAdvancedOptions(ArrayHelper::get($option, 'options', []))
255 + );
256 + continue;
257 + }
258 +
259 + if (!is_array($option)) {
260 + continue;
261 + }
262 +
263 + $flattened[] = $option;
264 + }
265 +
266 + return $flattened;
267 + }
268 +
269 + public static function advancedOptionsValueLabelMap($options)
270 + {
271 + $formatted = [];
272 +
273 + foreach (self::flattenAdvancedOptions($options) as $option) {
274 + $formatted[ArrayHelper::get($option, 'value')] = ArrayHelper::get($option, 'label');
275 + }
276 +
277 + return $formatted;
278 + }
279 +
59 280 public static function makeMenuUrl($page = 'fluent_forms_settings', $component = null)
60 281 {
61 282 $baseUrl = admin_url('admin.php?page=' . $page);
62 283
@@ -116,9 +337,9 @@
116 337 $statuses = apply_filters_deprecated(
117 338 'fluentform_entry_statuses_core',
118 339 [
119 340 $statuses,
120 - $form_id
341 + $form_id,
121 342 ],
122 343 FLUENTFORM_FRAMEWORK_UPGRADE,
123 344 'fluentform/entry_statuses_core',
124 345 'Use fluentform/entry_statuses_core instead of fluentform_entry_statuses_core.'
@@ -125,13 +346,21 @@
125 346 );
126 347
127 348 $statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);
128 349
129 - $statuses['trashed'] = 'Trashed';
350 + $statuses['spam'] = __('Spam', 'fluentform');
130 351
352 + $statuses['trashed'] = __('Trashed', 'fluentform');
353 +
131 354 return $statuses;
132 355 }
133 356
357 + // Statuses a caller may write by hand; add-ons withhold the ones they own as workflow steps.
358 + public static function getMutableEntryStatuses($form_id = false, $submission_id = null)
359 + {
360 + return apply_filters('fluentform/entry_statuses_for_mutation', static::getEntryStatuses($form_id), $form_id, $submission_id);
361 + }
362 +
134 363 public static function getReportableInputs()
135 364 {
136 365 $data = [
137 366 'select',
@@ -145,9 +374,9 @@
145 374
146 375 $data = apply_filters_deprecated(
147 376 'fluentform_reportable_inputs',
148 377 [
149 - $data
378 + $data,
150 379 ],
151 380 FLUENTFORM_FRAMEWORK_UPGRADE,
152 381 'fluentform/reportable_inputs',
153 382 'Use fluentform/reportable_inputs instead of fluentform_reportable_inputs.'
@@ -160,9 +389,9 @@
160 389 {
161 390 $grid = apply_filters_deprecated(
162 391 'fluentform_subfield_reportable_inputs',
163 392 [
164 - ['tabular_grid']
393 + ['tabular_grid'],
165 394 ],
166 395 FLUENTFORM_FRAMEWORK_UPGRADE,
167 396 'fluentform/subfield_reportable_inputs',
168 397 'Use fluentform/subfield_reportable_inputs instead of fluentform_subfield_reportable_inputs.'
@@ -173,9 +402,9 @@
173 402
174 403 public static function getFormMeta($formId, $metaKey, $default = '', $forced = false)
175 404 {
176 405 $formattedValues = self::$formMetaCache[$formId] ?? [];
177 -
406 +
178 407 if (!isset(self::$formMetaCache[$formId]) || $forced) {
179 408 $formMetas = FormMeta::where('form_id', $formId)
180 409 ->get();
181 410
@@ -181,9 +410,9 @@
181 410
182 411 $formattedValues = [];
183 412 foreach ($formMetas as $formMeta) {
184 413 $value = $formMeta->value;
185 -
414 +
186 415 $decoded = json_decode($value ?? '', true);
187 416 if (is_array($decoded)) {
188 417 $value = $decoded;
189 418 }
@@ -220,8 +449,25 @@
220 449 return null;
221 450 }
222 451 }
223 452
453 + /**
454 + * Resolve an entry's column => value map regardless of whether the entry is
455 + * a stdClass DB row (columns are real properties) or a WPFluent Model
456 + * (columns live in an internal attribute bag reached via __get).
457 + *
458 + * @param object|array $entry
459 + * @return array
460 + */
461 + public static function getEntryColumns($entry)
462 + {
463 + if (is_object($entry) && method_exists($entry, 'getAttributes')) {
464 + return $entry->getAttributes();
465 + }
466 +
467 + return (array) $entry;
468 + }
469 +
224 470 public static function getSubmissionMeta($submissionId, $metaKey, $default = false)
225 471 {
226 472 return SubmissionMeta::retrieve($metaKey, $submissionId, $default);
227 473 }
@@ -284,9 +530,9 @@
284 530 }
285 531
286 532 public static function getFormInstaceClass($formId)
287 533 {
288 - static::$formInstance += 1;
534 + static::$formInstance++;
289 535
290 536 return 'ff_form_instance_' . $formId . '_' . static::$formInstance;
291 537 }
292 538
@@ -304,10 +550,9 @@
304 550 'fluent_forms_settings',
305 551 'fluent_forms_add_ons',
306 552 'fluent_forms_docs',
307 553 'fluent_forms_payment_entries',
308 - 'fluent_forms_smtp',
309 - 'fluent_forms_reports'
554 + 'fluent_forms_reports',
310 555 ];
311 556
312 557 $status = true;
313 558
@@ -319,9 +564,9 @@
319 564
320 565 $status = apply_filters_deprecated(
321 566 'fluentform_is_admin_page',
322 567 [
323 - $status
568 + $status,
324 569 ],
325 570 FLUENTFORM_FRAMEWORK_UPGRADE,
326 571 'fluentform/is_admin_page',
327 572 'Use fluentform/is_admin_page instead of fluentform_is_admin_page.'
@@ -351,9 +596,9 @@
351 596
352 597 $result = shortcode_parse_atts($parsedCode);
353 598
354 599 if (!empty($result[$selector])) {
355 - if ($tag == 'fluentform' && !empty($result['type']) && $result['type'] == 'conversational') {
600 + if ('fluentform' == $tag && !empty($result['type']) && 'conversational' == $result['type']) {
356 601 continue;
357 602 }
358 603
359 604 $ids[$result[$selector]] = $result[$selector];
@@ -362,9 +607,9 @@
362 607
363 608 if ($theme) {
364 609 $attributes[] = [
365 610 'formId' => $result[$selector],
366 - 'theme' => $theme
611 + 'theme' => $theme,
367 612 ];
368 613 }
369 614 }
370 615 }
@@ -385,9 +630,9 @@
385 630 if (!function_exists('parse_blocks')) {
386 631 return $ids;
387 632 }
388 633
389 - $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block' . ' ');
634 + $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block ');
390 635
391 636 if (!$has_block) {
392 637 return $ids;
393 638 }
@@ -399,9 +644,9 @@
399 644 }
400 645
401 646 $hasBlock = strpos($block['blockName'], 'fluentfom/guten-block') === 0;
402 647 if ($hasBlock) {
403 - $formId = (int)$block['attrs']['formId'];
648 + $formId = (int) $block['attrs']['formId'];
404 649
405 650 $ids[] = $formId;
406 651
407 652 $theme = ArrayHelper::get($block, 'attrs.themeStyle');
@@ -408,9 +653,9 @@
408 653
409 654 if ($theme) {
410 655 $attributes[] = [
411 656 'formId' => $formId,
412 - 'theme' => $theme
657 + 'theme' => $theme,
413 658 ];
414 659 }
415 660 }
416 661 }
@@ -444,10 +689,10 @@
444 689 if (!$form) {
445 690 return false;
446 691 }
447 692
448 - $fieldsJson = (string)($form->form_fields ?? '');
449 - if ($fieldsJson === '') {
693 + $fieldsJson = (string) ($form->form_fields ?? '');
694 + if ('' === $fieldsJson) {
450 695 return false;
451 696 }
452 697
453 698 $fields = json_decode($fieldsJson, true);
@@ -454,9 +699,9 @@
454 699 if (!is_array($fields)) {
455 700 return false;
456 701 }
457 702
458 - return (bool)ArrayHelper::get($fields, 'stepsWrapper');
703 + return (bool) ArrayHelper::get($fields, 'stepsWrapper');
459 704 }
460 705
461 706 public static function hasFormElement($formId, $elementName)
462 707 {
@@ -478,10 +723,10 @@
478 723
479 724 // if form has pending payment then the value doesn't exist in EntryDetails table
480 725 // further checking on Submission table if the value exists
481 726 if (!$exist && $form->has_payment) {
482 - $escapedKey = json_encode($fieldName);
483 - $escapedValue = json_encode($inputValue);
727 + $escapedKey = wp_json_encode($fieldName);
728 + $escapedValue = wp_json_encode($inputValue);
484 729 $searchPattern = trim($escapedKey, '"') . '":' . $escapedValue;
485 730 $searchPattern = addcslashes($searchPattern, '%_');
486 731
487 732 $exist = Submission::where('form_id', $form->id)
@@ -566,9 +811,9 @@
566 811
567 812 $data = apply_filters_deprecated(
568 813 'fluentform_numeric_styles',
569 814 [
570 - $data
815 + $data,
571 816 ],
572 817 FLUENTFORM_FRAMEWORK_UPGRADE,
573 818 'fluentform/numeric_styles',
574 819 'Use fluentform/numeric_styles instead of fluentform_numeric_styles.'
@@ -617,8 +862,27 @@
617 862
618 863 return array_diff_assoc($inputNames, $uniqueNames);
619 864 }
620 865
866 + public static function getRankingFieldsWithDuplicateOptionValues($fields)
867 + {
868 + if (is_string($fields)) {
869 + $fields = json_decode($fields, true);
870 + }
871 +
872 + if (!is_array($fields)) {
873 + return [];
874 + }
875 +
876 + $items = ArrayHelper::get($fields, 'fields', []);
877 +
878 + if (!is_array($items)) {
879 + return [];
880 + }
881 +
882 + return static::collectRankingFieldsWithDuplicateOptionValues($items);
883 + }
884 +
621 885 protected static function getFieldNamesStatuses($fields)
622 886 {
623 887 $names = [];
624 888
@@ -628,16 +892,76 @@
628 892 foreach ($columns as $column) {
629 893 $columnInputs = static::getFieldNamesStatuses(ArrayHelper::get($column, 'fields', []));
630 894 $names = array_merge($names, $columnInputs);
631 895 }
632 - } else {
633 - if ($name = ArrayHelper::get($field, 'attributes.name')) {
896 + } elseif ($name = ArrayHelper::get($field, 'attributes.name')) {
634 897 $names[] = $name;
898 + }
899 + }
900 +
901 + return $names;
902 + }
903 +
904 + protected static function collectRankingFieldsWithDuplicateOptionValues($fields)
905 + {
906 + $duplicates = [];
907 +
908 + foreach ($fields as $field) {
909 + if ('container' === ArrayHelper::get($field, 'element')) {
910 + $columns = ArrayHelper::get($field, 'columns', []);
911 + foreach ($columns as $column) {
912 + $columnFields = ArrayHelper::get($column, 'fields', []);
913 + $duplicates = array_merge(
914 + $duplicates,
915 + static::collectRankingFieldsWithDuplicateOptionValues($columnFields)
916 + );
635 917 }
918 + continue;
636 919 }
920 +
921 + if (!empty($field['fields']) && is_array($field['fields'])) {
922 + $duplicates = array_merge(
923 + $duplicates,
924 + static::collectRankingFieldsWithDuplicateOptionValues($field['fields'])
925 + );
926 + continue;
927 + }
928 +
929 + if ('input_ranking' !== ArrayHelper::get($field, 'element')) {
930 + continue;
931 + }
932 +
933 + $formattedOptions = ArrayHelper::get($field, 'settings.advanced_options', []);
934 + if (!$formattedOptions) {
935 + $formattedOptions = [];
936 + foreach (ArrayHelper::get($field, 'options', []) as $value => $label) {
937 + $formattedOptions[] = [
938 + 'label' => $label,
939 + 'value' => $value,
940 + ];
941 + }
942 + }
943 +
944 + $optionValues = array_values(array_filter(array_map(
945 + 'sanitize_text_field',
946 + array_column(static::flattenAdvancedOptions($formattedOptions), 'value')
947 + ), function ($value) {
948 + return '' !== $value;
949 + }));
950 +
951 + if (count($optionValues) !== count(array_unique($optionValues))) {
952 + $fieldLabel = ArrayHelper::get($field, 'settings.admin_field_label');
953 + if (!$fieldLabel) {
954 + $fieldLabel = ArrayHelper::get($field, 'settings.label');
955 + }
956 + if (!$fieldLabel) {
957 + $fieldLabel = ArrayHelper::get($field, 'attributes.name');
958 + }
959 + $duplicates[] = $fieldLabel ? $fieldLabel : __('Ranking Field', 'fluentform');
960 + }
637 961 }
638 962
639 - return $names;
963 + return $duplicates;
640 964 }
641 965
642 966 public static function isConversionForm($formId)
643 967 {
@@ -656,12 +980,10 @@
656 980 if ('conversational' == $type) {
657 981 return static::getConversionUrl($formId);
658 982 } elseif ('classic' == $type) {
659 983 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
660 - } else {
661 - if (static::isConversionForm($formId)) {
984 + } elseif (static::isConversionForm($formId)) {
662 985 return static::getConversionUrl($formId);
663 - }
664 986 }
665 987
666 988 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
667 989 }
@@ -687,9 +1009,9 @@
687 1009
688 1010 $slug = apply_filters_deprecated(
689 1011 'fluentform_conversational_url_slug',
690 1012 [
691 - 'fluent-form'
1013 + 'fluent-form',
692 1014 ],
693 1015 FLUENTFORM_FRAMEWORK_UPGRADE,
694 1016 'fluentform/conversational_url_slug',
695 1017 'Use fluentform/conversational_url_slug instead of fluentform_conversational_url_slug.'
@@ -721,9 +1043,9 @@
721 1043
722 1044 $locations = apply_filters_deprecated(
723 1045 'fluentform_file_upload_options',
724 1046 [
725 - $locations
1047 + $locations,
726 1048 ],
727 1049 FLUENTFORM_FRAMEWORK_UPGRADE,
728 1050 'fluentform/file_upload_options',
729 1051 'Use fluentform/file_upload_options instead of fluentform_file_upload_options'
@@ -775,9 +1097,9 @@
775 1097 }
776 1098
777 1099 public static function sanitizeForCSV($content)
778 1100 {
779 - $formulas = ['=', '-', '+', '@', "\t", "\r"];
1101 + $formulas = ['=', '-', '+', '@', "\t", "\r", "\n"];
780 1102
781 1103 $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
782 1104
783 1105 if (Str::startsWith($content, $formulas)) {
@@ -806,9 +1128,9 @@
806 1128 $isTruncate = apply_filters_deprecated(
807 1129 'fluentform_truncate_password_values',
808 1130 [
809 1131 true,
810 - $formId
1132 + $formId,
811 1133 ],
812 1134 FLUENTFORM_FRAMEWORK_UPGRADE,
813 1135 'fluentform/truncate_password_values',
814 1136 'Use fluentform/truncate_password_values instead of fluentform_truncate_password_values.'
@@ -828,10 +1150,9 @@
828 1150 $field,
829 1151 $rowJoiner = '<br />',
830 1152 $colJoiner = ', ',
831 1153 $type = ''
832 - )
833 - {
1154 + ) {
834 1155 if (!$girdData || !$field) {
835 1156 return '';
836 1157 }
837 1158 $girdRows = ArrayHelper::get($field, 'raw.settings.grid_rows', []);
@@ -856,9 +1177,9 @@
856 1177 $_colJoiner = $colJoiner;
857 1178 if ($girdCols && isset($girdCols[$item])) {
858 1179 $item = $girdCols[$item];
859 1180 }
860 - if ($index == (count($column) - 1)) {
1181 + if ((count($column) - 1) == $index) {
861 1182 $_colJoiner = '';
862 1183 }
863 1184 $value .= $item . $_colJoiner;
864 1185 }
@@ -933,9 +1254,9 @@
933 1254
934 1255 public static function isAutosaveEnabled()
935 1256 {
936 1257 $autosaveEnabled = ArrayHelper::get(get_option('_fluentform_global_form_settings'), 'misc.autosave_enabled', 'no');
937 - return $autosaveEnabled === 'yes';
1258 + return 'yes' === $autosaveEnabled;
938 1259 }
939 1260
940 1261 public static function maybeDecryptUrl($url)
941 1262 {
@@ -961,9 +1282,9 @@
961 1282
962 1283 public static function isBlockEditor()
963 1284 {
964 1285 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
965 - return defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && $_REQUEST['context'] === 'edit';
1286 + return defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context'];
966 1287 }
967 1288
968 1289 public static function resolveValidationRulesGlobalOption(&$field)
969 1290 {
@@ -970,16 +1291,14 @@
970 1291 if (isset($field['fields']) && is_array($field['fields'])) {
971 1292 foreach ($field['fields'] as &$subField) {
972 1293 static::resolveValidationRulesGlobalOption($subField);
973 1294 }
974 - } else {
975 - if (ArrayHelper::get($field, 'settings.validation_rules')) {
976 - foreach ($field['settings']['validation_rules'] as $key => &$rule) {
977 - if (!isset($rule['global'])) {
978 - $rule['global'] = false;
979 - }
980 - $rule['global_message'] = static::getGlobalDefaultMessage($key);
1295 + } elseif (ArrayHelper::get($field, 'settings.validation_rules')) {
1296 + foreach ($field['settings']['validation_rules'] as $key => &$rule) {
1297 + if (!isset($rule['global'])) {
1298 + $rule['global'] = false;
981 1299 }
1300 + $rule['global_message'] = static::getGlobalDefaultMessage($key);
982 1301 }
983 1302 }
984 1303 }
985 1304
@@ -1014,9 +1333,10 @@
1014 1333 }
1015 1334 $fieldType = ArrayHelper::get($rawField, 'element');
1016 1335 $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1017 1336 $options = [];
1018 - if ("net_promoter_score" === $fieldType) {
1337 + $otherPrefix = '';
1338 + if ('net_promoter_score' === $fieldType) {
1019 1339 $options = array_flip(ArrayHelper::get($rawField, 'options', []));
1020 1340 } elseif ('ratings' == $fieldType) {
1021 1341 $options = array_keys(ArrayHelper::get($rawField, 'options', []));
1022 1342 } elseif ('gdpr_agreement' == $fieldType) {
@@ -1022,9 +1342,9 @@
1022 1342 } elseif ('gdpr_agreement' == $fieldType) {
1023 1343 $options = ['on'];
1024 1344 } elseif ('terms_and_condition' == $fieldType) {
1025 1345 $options = ['on', 'off'];
1026 - } elseif (in_array($fieldType, ['input_radio', 'select', 'input_checkbox'])) {
1346 + } elseif (in_array($fieldType, ['input_radio', 'select', 'input_checkbox', 'input_ranking'])) {
1027 1347 if (ArrayHelper::isTrue($rawField, 'attributes.multiple')) {
1028 1348 $fieldType = 'multi_select';
1029 1349 }
1030 1350 $formattedOptions = ArrayHelper::get($rawField, 'settings.advanced_options', []);
@@ -1038,17 +1358,18 @@
1038 1358 }
1039 1359 // @todo : Update all reference in form templates
1040 1360 }
1041 1361
1042 - $options = array_column($formattedOptions, 'value');
1043 -
1362 + $options = array_column(self::flattenAdvancedOptions($formattedOptions), 'value');
1363 +
1044 1364 // Add field-specific __ff_other__ to options if "Other" option is enabled
1045 1365 if (in_array($fieldType, ['input_checkbox', 'input_radio']) &&
1046 1366 ArrayHelper::get($rawField, 'settings.enable_other_option') === 'yes') {
1047 1367 $fieldName = sanitize_key(str_replace(['[', ']'], '', ArrayHelper::get($rawField, 'attributes.name', '')));
1048 1368 $options[] = '__ff_other_' . $fieldName . '__';
1369 + $otherPrefix = static::getOtherOptionValuePrefix($rawField);
1049 1370 }
1050 - } elseif ("dynamic_field" == $fieldType) {
1371 + } elseif ('dynamic_field' == $fieldType) {
1051 1372 $dynamicFetchValue = 'yes' == ArrayHelper::get($rawField, 'settings.dynamic_fetch');
1052 1373 if ($dynamicFetchValue) {
1053 1374 $rawField = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $rawField);
1054 1375 }
@@ -1067,8 +1388,34 @@
1067 1388 }
1068 1389
1069 1390 $isValid = true;
1070 1391 switch ($fieldType) {
1392 + case 'input_ranking':
1393 + $skipValidationInputsWithOptions = apply_filters('fluentform/skip_validation_inputs_with_options', false, $fieldType, $form, $formData);
1394 + if ($skipValidationInputsWithOptions) {
1395 + break;
1396 + }
1397 +
1398 + if (!is_array($inputValue)) {
1399 + $isValid = false;
1400 + break;
1401 + }
1402 +
1403 + $filteredValues = array_values(array_filter(array_map('sanitize_text_field', $inputValue), function ($value) {
1404 + return '' !== $value;
1405 + }));
1406 +
1407 + $normalizedOptions = array_values(array_filter(array_map('sanitize_text_field', $options), function ($value) {
1408 + return '' !== $value;
1409 + }));
1410 +
1411 + sort($filteredValues);
1412 + sort($normalizedOptions);
1413 +
1414 + $isValid = count($inputValue) === count($options)
1415 + && count($filteredValues) === count(array_unique($filteredValues))
1416 + && $filteredValues === $normalizedOptions;
1417 + break;
1071 1418 case 'input_radio':
1072 1419 case 'select':
1073 1420 case 'net_promoter_score':
1074 1421 case 'ratings':
@@ -1076,30 +1423,28 @@
1076 1423 case 'terms_and_condition':
1077 1424 case 'input_checkbox':
1078 1425 case 'multi_select':
1079 1426 case 'dynamic_field_options':
1080 -
1081 1427 $skipValidationInputsWithOptions = apply_filters('fluentform/skip_validation_inputs_with_options', false, $fieldType, $form, $formData);
1082 1428 if ($skipValidationInputsWithOptions) {
1083 1429 break;
1084 1430 }
1085 1431 if (is_array($inputValue)) {
1086 - // Handle field-specific "Other" options for checkboxes
1087 - $filteredValues = array_filter($inputValue, function($value) {
1088 - // Skip field-specific other values and processed other values
1432 + // Skip "Other" values — raw, localized or legacy English prefix
1433 + $filteredValues = array_filter($inputValue, function ($value) use ($otherPrefix) {
1089 1434 return !preg_match('/^__ff_other_.*__$/', $value) &&
1090 - !preg_match('/^Other:\s/', $value);
1435 + !preg_match('/^Other:\s/', $value) &&
1436 + !($otherPrefix && 0 === strpos($value, $otherPrefix));
1091 1437 });
1092 1438 $isValid = array_diff($filteredValues, $options);
1093 1439 $isValid = empty($isValid);
1440 + } elseif (preg_match('/^__ff_other_.*__$/', $inputValue) ||
1441 + preg_match('/^Other:\s/', $inputValue) ||
1442 + ($otherPrefix && 0 === strpos($inputValue, $otherPrefix))) {
1443 + // Accept "Other" values — raw, localized or legacy English prefix
1444 + $isValid = true;
1094 1445 } else {
1095 - // Handle field-specific "Other" option for single values
1096 - if (preg_match('/^__ff_other_.*__$/', $inputValue) ||
1097 - preg_match('/^Other:\s/', $inputValue)) {
1098 - $isValid = true;
1099 - } else {
1100 - $isValid = in_array($inputValue, $options);
1101 - }
1446 + $isValid = in_array($inputValue, $options);
1102 1447 }
1103 1448 break;
1104 1449 case 'input_number':
1105 1450 if (is_array($inputValue)) {
@@ -1114,9 +1459,9 @@
1114 1459 case 'select_country':
1115 1460 $fieldData = ArrayHelper::get($field, 'raw');
1116 1461 $data = (new SelectCountry())->loadCountries($fieldData);
1117 1462 $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
1118 - $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
1463 + $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
1119 1464 $isValid = in_array($inputValue, $validCountries);
1120 1465 break;
1121 1466 case 'repeater_field':
1122 1467 case 'repeater_container':
@@ -1161,8 +1506,137 @@
1161 1506 }
1162 1507 return $error;
1163 1508 }
1164 1509
1510 + /**
1511 + * Enforce how many options a field allows the user to pick.
1512 + *
1513 + * A field with no selections is left alone, so a floor never turns an
1514 + * optional field into a required one — that is what `required` is for.
1515 + *
1516 + * @param array $rawField
1517 + * @param mixed $inputValue
1518 + * @return array rule name => message, empty when within the limits
1519 + */
1520 + public static function validateSelectionLimits($rawField, $inputValue)
1521 + {
1522 + // Distinct choices, not array entries. The same option repeated is one
1523 + // answer: counting entries lets a crafted post satisfy a floor of two by
1524 + // sending one option twice, and lets padding manufacture a ceiling breach
1525 + // the visitor never made. No UI can produce a duplicate, so this only
1526 + // ever arrives crafted. A filled-in "Other" is a single element and still
1527 + // counts once.
1528 + $selected = is_array($inputValue) ? count(array_unique(array_filter($inputValue, function ($value) {
1529 + return '' !== $value && null !== $value;
1530 + }))) : (('' === $inputValue || null === $inputValue) ? 0 : 1);
1531 +
1532 + if (!$selected) {
1533 + return [];
1534 + }
1535 +
1536 + $rules = ArrayHelper::get($rawField, 'settings.validation_rules', []);
1537 +
1538 + $limits = [
1539 + 'min_selection' => ArrayHelper::get($rules, 'min_selection.value'),
1540 + 'max_selection' => static::resolveMaxSelection($rawField),
1541 + ];
1542 +
1543 + $errors = [];
1544 +
1545 + foreach ($limits as $rule => $limit) {
1546 + // '' is how "no limit" ships, so it must never mean a limit of zero.
1547 + if ('' === $limit || null === $limit || !is_numeric($limit)) {
1548 + continue;
1549 + }
1550 +
1551 + $limit = (int) $limit;
1552 +
1553 + if ($limit < 1) {
1554 + continue;
1555 + }
1556 +
1557 + $breached = 'min_selection' === $rule ? $selected < $limit : $selected > $limit;
1558 +
1559 + if ($breached) {
1560 + $errors[$rule] = static::getSelectionLimitMessage($rules, $rule);
1561 + }
1562 + }
1563 +
1564 + return $errors;
1565 + }
1566 +
1567 + /**
1568 + * The effective ceiling for a field, preferring the rule over the legacy
1569 + * `settings.max_selection`.
1570 + *
1571 + * The rule takes ownership as soon as its KEY exists, empty value included.
1572 + * Falling back on an empty value instead would make the limit unremovable:
1573 + * nothing writes to the legacy setting any more, so clearing the box in the
1574 + * editor would silently drop back to whatever was frozen there.
1575 + *
1576 + * @param array $field
1577 + * @return mixed
1578 + */
1579 + public static function resolveMaxSelection($field)
1580 + {
1581 + $rules = ArrayHelper::get($field, 'settings.validation_rules', []);
1582 +
1583 + if (is_array($rules) && array_key_exists('max_selection', $rules)) {
1584 + return ArrayHelper::get($rules, 'max_selection.value');
1585 + }
1586 +
1587 + return ArrayHelper::get($field, 'settings.max_selection');
1588 + }
1589 +
1590 + /**
1591 + * The field's own wording for a breached limit, or the site-wide default.
1592 + *
1593 + * @param array $rules
1594 + * @param string $rule
1595 + * @return string
1596 + */
1597 + public static function getSelectionLimitMessage($rules, $rule)
1598 + {
1599 + // `global_message` is a copy taken when the field was last saved, so it
1600 + // goes stale the moment Global Settings change — resolve the live value
1601 + // the way every other rule does.
1602 + $message = ArrayHelper::isTrue($rules, $rule . '.global')
1603 + ? static::getGlobalDefaultMessage($rule)
1604 + : ArrayHelper::get($rules, $rule . '.message');
1605 +
1606 + if (!$message) {
1607 + $message = static::getGlobalDefaultMessage($rule);
1608 + }
1609 +
1610 + return apply_filters('fluentform/selection_limit_message', $message, $rule, $rules);
1611 + }
1612 +
1613 + /**
1614 + * Prefix used to store a checkable field's "Other" option value,
1615 + * built from the field's own (translated) label. Pass $form to run
1616 + * the field through the rendering filter (translation plugins) first.
1617 + *
1618 + * @param array $rawField
1619 + * @param object|null $form
1620 + * @return string
1621 + */
1622 + public static function getOtherOptionValuePrefix($rawField, $form = null)
1623 + {
1624 + $fieldType = ArrayHelper::get($rawField, 'element');
1625 + if ($form && $fieldType) {
1626 + $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1627 + }
1628 +
1629 + $label = trim((string) ArrayHelper::get($rawField, 'settings.other_option_label'));
1630 +
1631 + if ('' === $label) {
1632 + $label = __('Other', 'fluentform');
1633 + }
1634 +
1635 + // Avoid "::" when the label already ends with a colon
1636 + return ':' === substr($label, -1) ? $label . ' ' : $label . ': ';
1637 + }
1638 +
1165 1639 public static function getWhiteListedFields($formId)
1166 1640 {
1167 1641 $whiteListedFields = [
1168 1642 '__fluent_form_embded_post_id',
@@ -1176,9 +1650,9 @@
1176 1650 '__entry_intermediate_hash',
1177 1651 '__square_payment_method_id',
1178 1652 '__square_verify_buyer_id',
1179 1653 'ct_bot_detector_event_token',
1180 - 'ff_ct_form_load_time'
1654 + 'ff_ct_form_load_time',
1181 1655 ];
1182 1656
1183 1657 return apply_filters('fluentform/white_listed_fields', $whiteListedFields, $formId);
1184 1658 }
@@ -1184,8 +1658,9 @@
1184 1658 }
1185 1659
1186 1660 /**
1187 1661 * Shortcode parse on validation message
1662 + *
1188 1663 * @param string $message
1189 1664 * @param object $form
1190 1665 * @param string $fieldName
1191 1666 * @return string
@@ -1192,9 +1667,9 @@
1192 1667 */
1193 1668 public static function shortCodeParseOnValidationMessage($message, $form, $fieldName)
1194 1669 {
1195 1670 // Return early if form is null to prevent errors
1196 - if ($form === null) {
1671 + if (null === $form) {
1197 1672 return $message;
1198 1673 }
1199 1674
1200 1675 // For validation message there is no entry & form data
@@ -1200,9 +1675,9 @@
1200 1675 // For validation message there is no entry & form data
1201 1676 // Add 'current_field' name as data array to resolve {labels.current_field} shortcode if it has
1202 1677 return ShortCodeParser::parse(
1203 1678 $message,
1204 - (object)['response' => "", 'form_id' => $form->id],
1679 + (object) ['response' => '', 'form_id' => $form->id],
1205 1680 ['current_field' => $fieldName],
1206 1681 $form
1207 1682 );
1208 1683 }
@@ -1300,8 +1775,25 @@
1300 1775 {
1301 1776 return defined('FLUENTFORMPRO');
1302 1777 }
1303 1778
1779 + public static function utmUrl($baseUrl, $utmContent = '', $utmCampaign = 'upgrade_pro')
1780 + {
1781 + $params = [
1782 + 'utm_source' => 'fluent-forms',
1783 + 'utm_medium' => self::hasPro() ? 'pro_plugin' : 'free_plugin',
1784 + 'utm_campaign' => $utmCampaign,
1785 + 'utm_term' => FLUENTFORM_VERSION,
1786 + 'theme_style' => fluentform_get_active_theme_slug(),
1787 + ];
1788 +
1789 + if ($utmContent) {
1790 + $params['utm_content'] = $utmContent;
1791 + }
1792 +
1793 + return add_query_arg($params, $baseUrl);
1794 + }
1795 +
1304 1796 public static function getLandingPageEnabledForms()
1305 1797 {
1306 1798 if (class_exists(\FluentFormPro\classes\SharePage\SharePage::class)) {
1307 1799 if (method_exists(\FluentFormPro\classes\SharePage\SharePage::class, 'getLandingPageFormIds')) {
@@ -1329,10 +1821,17 @@
1329 1821 {
1330 1822 return home_url($args);
1331 1823 }
1332 1824
1333 - public static function getCountryCodeFromHeaders()
1825 + public static function getCountryCodeFromHeaders($forRestriction = false)
1334 1826 {
1827 + // SECURITY (FINDING-26): CDN country headers are client-spoofable. Trust them for analytics
1828 + // storage (spoof is cosmetic) but not for restriction enforcement (spoof = bypass). Filterable.
1829 + $trustHeaders = apply_filters('fluentform/trust_geo_headers', !$forRestriction);
1830 + if (!$trustHeaders) {
1831 + return null;
1832 + }
1833 +
1335 1834 $headers = [
1336 1835 // Cloudflare (most common)
1337 1836 'HTTP_CF_IPCOUNTRY',
1338 1837 'CF-IPCountry',
@@ -1356,9 +1855,9 @@
1356 1855 // General purpose country headers
1357 1856 'HTTP_X_COUNTRY',
1358 1857 'X-Country',
1359 1858 'HTTP_X_COUNTRY_ISO',
1360 - 'X-Country-ISO'
1859 + 'X-Country-ISO',
1361 1860 ];
1362 1861
1363 1862 foreach ($headers as $header) {
1364 1863 // Try directly from $_SERVER
@@ -1364,10 +1863,10 @@
1364 1863 // Try directly from $_SERVER
1365 1864 if (isset($_SERVER[$header])) {
1366 1865 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1367 1866 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$header])));
1368 - } // Try with HTTP_ prefix if not already present
1369 - elseif (strpos($header, 'HTTP_') !== 0) {
1867 + } elseif (strpos($header, 'HTTP_') !== 0) {
1868 + // Try with HTTP_ prefix if not already present
1370 1869 $httpHeader = 'HTTP_' . str_replace('-', '_', strtoupper($header));
1371 1870 if (isset($_SERVER[$httpHeader])) {
1372 1871 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1373 1872 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$httpHeader])));
@@ -1378,9 +1877,9 @@
1378 1877 continue;
1379 1878 }
1380 1879
1381 1880 // Basic validation - should be 2-letter country code
1382 - if (!empty($code) && is_string($code) && strlen($code) === 2 && ctype_alpha($code) && $code !== 'XX') {
1881 + if (!empty($code) && is_string($code) && 2 === strlen($code) && ctype_alpha($code) && 'XX' !== $code) {
1383 1882 return strtoupper($code);
1384 1883 }
1385 1884 }
1386 1885
@@ -1388,8 +1887,9 @@
1388 1887 }
1389 1888
1390 1889 /**
1391 1890 * Fixes PHP Object Injection Vulnerability
1891 + *
1392 1892 * @param $data
1393 1893 * @return mixed
1394 1894 */
1395 1895 public static function safeUnserialize($data)
@@ -1399,12 +1899,13 @@
1399 1899 }
1400 1900 return $data;
1401 1901 }
1402 1902
1403 - /**
1404 - * If elementor editor is open
1405 - * @return bool
1406 - */
1903 + /**
1904 + * If elementor editor is open
1905 + *
1906 + * @return bool
1907 + */
1407 1908 public static function isElementorEditor()
1408 1909 {
1409 1910 return defined('ELEMENTOR_VERSION') &&
1410 1911 class_exists('\Elementor\Plugin') &&
@@ -1414,8 +1915,9 @@
1414 1915
1415 1916 /**
1416 1917 * Check if we're in block editor context (Site Editor, Template Editor, or Post/Page Editor)
1417 1918 * Covers all Gutenberg block editor contexts including mobile/tablet preview iframes
1919 + *
1418 1920 * @return bool
1419 1921 */
1420 1922 public static function isSiteEditor()
1421 1923 {
@@ -1444,9 +1946,9 @@
1444 1946 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- REQUEST_URI used for string comparison only
1445 1947 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
1446 1948 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking block editor context
1447 1949 return isset( $_GET['_wp-find-template'] ) ||
1448 - strpos( $request_uri, 'site-editor.php' ) !== false ||
1950 + strpos( $request_uri, 'site-editor.php' ) !== false ||
1449 1951 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1450 - (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && $_REQUEST['context'] === 'edit');
1952 + (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context']);
1451 1953 }
1452 1954 }