PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
← All changes | includes/Frontend/Form/FrontendFormManager.php +210 -24 3.2.03.3.1 View file →
@@ -17,8 +17,11 @@
17 17 use BitCode\BitForm\Core\Form\Validator\FormFieldValidator;
18 18 use BitCode\BitForm\Core\Integration\IntegrationHandler;
19 19 use BitCode\BitForm\Core\Messages\SuccessMessageHandler;
20 20 use BitCode\BitForm\Core\Util\ApiResponse as UtilApiResponse;
21 +use BitCode\BitForm\Core\Util\EscapingHelper;
22 +use BitCode\BitForm\Core\Util\FieldValueHandler;
23 +use BitCode\BitForm\Core\Util\FrontendHelpers;
21 24 use BitCode\BitForm\Core\Util\HttpHelper;
22 25 use BitCode\BitForm\Core\Util\IpTool;
23 26 use BitCode\BitForm\Core\Util\Utilities;
24 27 use BitCode\BitForm\Core\WorkFlow\WorkFlow;
@@ -77,9 +80,9 @@
77 80 // unset($submitted_data['bit-form-submit-btn']);
78 81 return array_keys($submitted_data);
79 82 }
80 83
81 - public function formView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null)
84 + public function formView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null, $isEntryEdit = false)
82 85 {
83 86 $formContents = $this->getFormContent();
84 87 $formAtomicClsMap = $this->getAtomicClsMap();
85 88 if (!empty($fields)) {
@@ -92,14 +95,14 @@
92 95 );
93 96 $formContents->fields = empty($workFlowreturnedOnLoad['fields']) ? $formContents->fields : $workFlowreturnedOnLoad['fields'];
94 97 }
95 98 $formViewer = new FormViewer($this, $formContents, $formAtomicClsMap, $errorMessages, $previousValue);
96 - $isRestricted = $this->checkSubmissionRestriction(false);
99 + $isRestricted = $this->checkSubmissionRestriction(false, $isEntryEdit);
97 100 $msg = !empty($isRestricted) ? $isRestricted[0] : '';
98 101 return $formViewer->getView($hasFile, $msg);
99 102 }
100 103
101 - public function conversationalFormView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null)
104 + public function conversationalFormView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null, $isEntryEdit = false)
102 105 {
103 106 $formContents = $this->getFormContent();
104 107 $formAtomicClsMap = $this->getAtomicClsMap();
105 108 if (!empty($fields)) {
@@ -112,14 +115,14 @@
112 115 );
113 116 $formContents->fields = empty($workFlowreturnedOnLoad['fields']) ? $formContents->fields : $workFlowreturnedOnLoad['fields'];
114 117 }
115 118 $formViewer = new FormViewer($this, $formContents, $formAtomicClsMap, $errorMessages, $previousValue);
116 - $isRestricted = $this->checkSubmissionRestriction(false);
119 + $isRestricted = $this->checkSubmissionRestriction(false, $isEntryEdit);
117 120 $msg = !empty($isRestricted) ? $isRestricted[0] : '';
118 121 return $formViewer->getConversationalView($hasFile, $msg);
119 122 }
120 123
121 - public function checkEmptySubmission($data, $file)
124 + public function checkEmptySubmission($data, $file, $isEntryEdit = false)
122 125 {
123 126 $formFields = $this->getFields();
124 127 foreach ($formFields as $key => $field) {
125 128 $fieldType = $field['type'];
@@ -130,8 +133,16 @@
130 133 if ('decision-box' === $fieldType || 'gdpr' === $fieldType) {
131 134 continue;
132 135 }
133 136 $isFileType = in_array($fieldType, $fileUploadFieldTypes);
137 + // An edit keeps an untouched file/signature as `<fieldKey>_old`, not as an upload.
138 + if (
139 + $isEntryEdit
140 + && ($isFileType || 'signature' === $fieldType)
141 + && !empty(FieldValueHandler::retainedOldValues($data, $key))
142 + ) {
143 + return false;
144 + }
134 145 if ($this->isRepeatedField($key)) {
135 146 $fileData = !empty($file[$key]) ? $file[$key] : [];
136 147 $dataVal = !empty($data[$key]) ? $data[$key] : [];
137 148 if (!$this->checkRepeatedFieldEmptySubmission($isFileType, $dataVal, $fileData)) {
@@ -219,8 +230,74 @@
219 230
220 231 return $post;
221 232 }
222 233
234 + /**
235 + * WP auth errors carry markup and the confirmation box paints them with innerHTML,
236 + * so esc_html() would show the tags as text. kses keeps only the safe markup.
237 + *
238 + * @param mixed $message
239 + *
240 + * @return string
241 + */
242 + private static function authErrorMessage($message)
243 + {
244 + return wp_kses(is_string($message) ? $message : '', EscapingHelper::getAllowedHtmlTags());
245 + }
246 +
247 + /**
248 + * A confirm-enabled email/password field posts as one composite and the validator collapses it
249 + * to the primary value, so the confirm child's own field key never reaches $_POST. WP auth
250 + * integrations map fields by key, so fill those child keys on a copy for the auth filter.
251 + *
252 + * @param mixed $postData
253 + *
254 + * @return mixed
255 + */
256 + private function resolveConfirmChildValues($postData)
257 + {
258 + if (!is_array($postData)) {
259 + return $postData;
260 + }
261 + $fields = $this->getFields();
262 + foreach ($fields as $fieldKey => $fieldData) {
263 + if (
264 + empty($fieldData['childFields'])
265 + || !isset($fieldData['type'])
266 + || !in_array($fieldData['type'], ['email', 'password'], true)
267 + || !empty($fieldData['repeated'])
268 + || !isset($postData[$fieldKey])
269 + ) {
270 + continue;
271 + }
272 + $parentValue = $postData[$fieldKey];
273 + foreach ((array) $fieldData['childFields'] as $childFieldRef) {
274 + $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : '';
275 + if (
276 + empty($childKey)
277 + || !isset($fields[$childKey])
278 + || !empty($fields[$childKey]['isDeactive'])
279 + || isset($postData[$childKey])
280 + ) {
281 + continue;
282 + }
283 + if (is_array($parentValue)) {
284 + if (array_key_exists('confirm', $parentValue)) {
285 + $postData[$childKey] = $parentValue['confirm'];
286 + }
287 + continue;
288 + }
289 + // Validation matched primary against confirm before collapsing, so this is that value.
290 + $postData[$childKey] = $parentValue;
291 + }
292 + if (is_array($parentValue) && array_key_exists('primary', $parentValue)) {
293 + $postData[$fieldKey] = $parentValue['primary'];
294 + }
295 + }
296 +
297 + return $postData;
298 + }
299 +
223 300 public function handleSubmission()
224 301 {
225 302 // CSRF verified via verifySubmissionNonce() before this method is called. All $_POST reads below occur after that verification.
226 303 $this->fieldNameReplaceOfPost();
@@ -230,9 +307,9 @@
230 307 $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id);
231 308
232 309 if (true === $validated) {
233 310 do_action('bitform_validation_success', $this->_form_id);
234 - unset($_POST['hidden_fields']);
311 + $this->discardHiddenFieldValues();
235 312
236 313 $redirectPage = '';
237 314 $regSuccMsg = '';
238 315
@@ -242,17 +319,18 @@
242 319 $parameter = $this->getParams();
243 320 $existAuthFilter = has_filter('bitform_wp_user_auth');
244 321
245 322 if (true === $existAuthFilter) {
246 - $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $unslashed_post, $parameter);
323 + $authPostData = $this->resolveConfirmChildValues($unslashed_post);
324 + $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter);
247 325
248 - $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $unslashed_post, $parameter);
326 + $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter);
249 327
250 - do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $unslashed_post, $parameter);
328 + do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter);
251 329
252 330 if (isset($result['auth_type']) && 'register' === $result['auth_type']) {
253 331 if (!$result['success']) {
254 - return new WP_Error('errors', esc_html($result['message']));
332 + return new WP_Error('errors', self::authErrorMessage($result['message']));
255 333 } elseif (isset($result['success'])) {
256 334 $redirectPage = $result['redirectPage'];
257 335 $regSuccMsg = $result['message'];
258 336 }
@@ -257,9 +335,9 @@
257 335 $regSuccMsg = $result['message'];
258 336 }
259 337 } else {
260 338 if (!$result['success']) {
261 - return new WP_Error('errors', esc_html($result['message']));
339 + return new WP_Error('errors', self::authErrorMessage($result['message']));
262 340 } else {
263 341 return $result;
264 342 }
265 343 }
@@ -331,9 +409,9 @@
331 409 public function handleUpdateEntry()
332 410 {
333 411 // Entry token or capability verified by caller (FrontendAjax::update_entry). All $_POST reads occur after that check.
334 412 $this->fieldNameReplaceOfPost();
335 - $validated = $this->beforeSubmittedValidate();
413 + $validated = $this->beforeSubmittedValidate(true, true);
336 414 $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id);
337 415
338 416 $entryID = isset($_REQUEST['entryID']) ? sanitize_text_field(wp_unslash($_REQUEST['entryID'])) : null;
339 417 $GLOBALS['bitform_entry_id'] = $entryID;
@@ -341,9 +419,10 @@
341 419 return new WP_Error('empty_form', __('Entries id is invalid', 'bit-form'));
342 420 }
343 421 if (true === $validated) {
344 422 do_action('bitform_validation_success', $this->_form_id);
345 - unset($_POST['hidden_fields'], $_POST['entryID']);
423 + $this->discardHiddenFieldValues();
424 + unset($_POST['entryID']);
346 425
347 426 $redirectPage = '';
348 427 $regSuccMsg = '';
349 428 $postData = wp_unslash($_POST);
@@ -353,13 +432,14 @@
353 432 $parameter = $this->getParams();
354 433 $existAuthFilter = has_filter('bitform_wp_user_auth');
355 434
356 435 if (true === $existAuthFilter) {
357 - $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $postData, $parameter);
436 + $authPostData = $this->resolveConfirmChildValues($postData);
437 + $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter);
358 438
359 439 if (isset($result['auth_type']) && 'register' === $result['auth_type']) {
360 440 if (!$result['success']) {
361 - return new WP_Error('errors', esc_html($result['message']));
441 + return new WP_Error('errors', self::authErrorMessage($result['message']));
362 442 } elseif (isset($result['success'])) {
363 443 $redirectPage = $result['redirectPage'];
364 444 $regSuccMsg = $result['message'];
365 445 }
@@ -364,9 +444,9 @@
364 444 $regSuccMsg = $result['message'];
365 445 }
366 446 } else {
367 447 if (!$result['success']) {
368 - return new WP_Error('errors', esc_html($result['message']));
448 + return new WP_Error('errors', self::authErrorMessage($result['message']));
369 449 } else {
370 450 return $result;
371 451 }
372 452 }
@@ -435,11 +515,96 @@
435 515 do_action('bitform_validation_error', $this->_form_id, $validated);
436 516 return $validated;
437 517 }
438 518
519 + /**
520 + * Drop the posted `hidden_fields` transport key and, when the form opts in, the values of
521 + * the fields it names.
522 + *
523 + * A hidden field keeps its typed value in the DOM, so the browser still submits it. Runs
524 + * here because it is the last point before entry, notifications and integrations are built
525 + * from $_POST.
526 + *
527 + * @return void
528 + */
529 + private function discardHiddenFieldValues()
530 + {
531 + // CSRF verified upstream via verifySubmissionNonce(); $_POST is only being narrowed here.
532 + $rawHiddenFields = isset($_POST['hidden_fields']) ? wp_unslash($_POST['hidden_fields']) : '';
533 + unset($_POST['hidden_fields']);
534 +
535 + if (!$this->shouldDiscardHiddenFieldValues()) {
536 + return;
537 + }
538 + $hiddenFieldKeys = FrontendHelpers::parseHiddenFieldKeys($rawHiddenFields);
539 + if (empty($hiddenFieldKeys)) {
540 + return;
541 + }
542 +
543 + $formFields = $this->getFields();
544 + foreach ($hiddenFieldKeys as $fieldKey) {
545 + if (!isset($formFields[$fieldKey])) {
546 + continue;
547 + }
548 + $field = $formFields[$fieldKey];
549 + // The posted list also names builder-hidden and hidden-type fields, which carry a value
550 + // on purpose. Only what conditional logic hid is discarded.
551 + if ('hidden' === $field['type'] || !empty($field['valid']['hide'])) {
552 + continue;
553 + }
554 + // Hiding flags a repeater child once, not per row, so discarding would wipe the column
555 + // in every row.
556 + if (!empty($field['repeated'])) {
557 + continue;
558 + }
559 + // Calculation and tracking fields opt out.
560 + if (!empty($field['valid']['keepValueWhenHidden'])) {
561 + continue;
562 + }
563 + // A composite child (name/address/confirm) posts nested under its parent key.
564 + if (!empty($field['parentFieldKey'])) {
565 + $this->discardCompositeChildValue($formFields, $field, $fieldKey);
566 + continue;
567 + }
568 + unset($_POST[$fieldKey], $_FILES[$fieldKey]);
569 + }
570 + }
571 +
572 + /**
573 + * @param array $formFields
574 + * @param array $field the child field's config
575 + * @param string $fieldKey the child field's key
576 + *
577 + * @return void
578 + */
579 + private function discardCompositeChildValue($formFields, $field, $fieldKey)
580 + {
581 + $parentKey = $field['parentFieldKey'];
582 + if (!isset($_POST[$parentKey]) || !is_array($_POST[$parentKey])) {
583 + return;
584 + }
585 + $parentName = isset($formFields[$parentKey]['name']) ? $formFields[$parentKey]['name'] : '';
586 + $childName = FieldValueHandler::deriveChildName(isset($field['name']) ? $field['name'] : '', $parentName);
587 + unset($_POST[$parentKey][$childName], $_POST[$parentKey][$fieldKey]);
588 + }
589 +
590 + /**
591 + * @return bool
592 + */
593 + private function shouldDiscardHiddenFieldValues()
594 + {
595 + $formInfo = $this->getFormInfo();
596 + if (!is_object($formInfo) || !isset($formInfo->submissionSettings)) {
597 + return false;
598 + }
599 + $submissionSettings = (object) $formInfo->submissionSettings;
600 +
601 + return !empty($submissionSettings->discardHiddenFieldValues);
602 + }
603 +
439 604 public function validateFormSubmission($submitted_data)
440 605 {
441 - $hidden_fields = isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : '';
606 + $hidden_fields = FrontendHelpers::parseHiddenFieldKeys(isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : '');
442 607 $submitted_fields = $this->getSubmittedFields($submitted_data);
443 608 $form_fields = $this->getFields();
444 609 $form_fields_names = array_keys($form_fields);
445 610 if ($this->isGCLIDEnabled()) {
@@ -445,9 +610,9 @@
445 610 if ($this->isGCLIDEnabled()) {
446 611 array_push($form_fields_names, 'GCLID');
447 612 }
448 613 foreach ($submitted_fields as $field) {
449 - if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || false !== strpos($hidden_fields, $field)) {
614 + if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || FrontendHelpers::isFieldHidden($hidden_fields, $field)) {
450 615 unset($submitted_data[$field]);
451 616 }
452 617 }
453 618 return $submitted_data;
@@ -452,13 +617,13 @@
452 617 }
453 618 return $submitted_data;
454 619 }
455 620
456 - public function beforeSubmittedValidate($verifyCaptcha = true)
621 + public function beforeSubmittedValidate($verifyCaptcha = true, $isEntryEdit = false)
457 622 {
458 623 if ($this->verifySubmissionNonce()) {
459 624 if ($this->isExist()) {
460 - $isRestricted = $this->checkSubmissionRestriction();
625 + $isRestricted = $this->checkSubmissionRestriction(true, $isEntryEdit);
461 626 if ($isRestricted && !empty($isRestricted)) {
462 627 return new WP_Error('spam_detection', $isRestricted[0]);
463 628 }
464 629 $postData = wp_unslash($_POST);
@@ -555,9 +720,13 @@
555 720 $form_fields = $step_fields;
556 721 }
557 722 }
558 723 }
559 - $formFieldValidator = new FormFieldValidator($form_fields, $postData, $filesData);
724 + // Only an edit may satisfy a required upload/signature from a `_old` marker.
725 + $editedEntryID = $isEntryEdit && isset($_REQUEST['entryID'])
726 + ? sanitize_text_field(wp_unslash($_REQUEST['entryID']))
727 + : null;
728 + $formFieldValidator = new FormFieldValidator($form_fields, $postData, $filesData, $editedEntryID);
560 729 $validUniuqFields = [];
561 730 $existFilter = has_filter('bitform_check_duplicate_entry');
562 731 if (true === $existFilter) {
563 732 $validUniuqFields = apply_filters('bitform_check_duplicate_entry', $form_fields, $postData);
@@ -747,9 +916,9 @@
747 916 {
748 917 if (!current_user_can('manage_options')) {
749 918 $update_status = $this->formModel->update(
750 919 [
751 - 'views' => intval(static::$form[0]->views) + 1
920 + 'views' => intval($this->form[0]->views) + 1
752 921 ],
753 922 [
754 923 'id' => $this->form_id
755 924 ]
@@ -756,9 +925,13 @@
756 925 );
757 926 }
758 927 }
759 928
760 - public function checkSubmissionRestriction($checkedEmptySubmitted = true)
929 + /**
930 + * @param bool $checkedEmptySubmitted whether the empty-submission rule applies here
931 + * @param bool $isEntryEdit true when an existing entry is being updated
932 + */
933 + public function checkSubmissionRestriction($checkedEmptySubmitted = true, $isEntryEdit = false)
761 934 {
762 935 $formContents = $this->getFormContent();
763 936 $additionalSettings = isset($formContents->additional) ? $formContents->additional : null;
764 937 $fromRestrictionSetitingsEnabled = empty($additionalSettings->enabled) ? [] : $additionalSettings->enabled;
@@ -774,8 +947,21 @@
774 947 $currentUserId = get_current_user_id();
775 948
776 949 foreach ($fromRestrictionSetitingsEnabled as $restrictionKey => $isEnabled) {
777 950 if ($isEnabled) {
951 + // Quota rules gate creating an entry, so an edit skips them; access-control keys stay.
952 + $skippableOnEdit = ['onePerIp', 'entry_limit', 'entry_limit_by_user', 'restrict_form'];
953 + if ($isEntryEdit && in_array($restrictionKey, $skippableOnEdit, true)) {
954 + $skipOnEdit = apply_filters(
955 + 'bitform_skip_restriction_on_entry_edit',
956 + true,
957 + $restrictionKey,
958 + $this->form_id
959 + );
960 + if ($skipOnEdit) {
961 + continue;
962 + }
963 + }
778 964 /**
779 965 * Allow add-ons to handle any restriction key (Pro-only restrictions
780 966 * should be implemented in the add-on, not shipped in the free plugin).
781 967 *
@@ -845,9 +1031,9 @@
845 1031
846 1032 $restrictionMessage[] = $is_login_messages;
847 1033 }
848 1034 if ($checkedEmptySubmitted && 'empty_submission' === $restrictionKey) {
849 - $isEmpty = $this->checkEmptySubmission(wp_unslash($_POST), GlobalHelper::sanitize_files_input($_FILES));
1035 + $isEmpty = $this->checkEmptySubmission(wp_unslash($_POST), GlobalHelper::sanitize_files_input($_FILES), $isEntryEdit);
850 1036 if ($isEmpty) {
851 1037 $restriction = $fromRestrictionSetitings->empty_submission->message;
852 1038
853 1039 $restriction = apply_filters(