PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 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 All 139 releases
← All changes | includes/Frontend/Form/FrontendFormManager.php +468 -226 3.2.1 → V3.3.2 View file →
@@ -17,10 +17,14 @@
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;
26 +use BitCode\BitForm\Core\Util\Log;
23 27 use BitCode\BitForm\Core\Util\Utilities;
24 28 use BitCode\BitForm\Core\WorkFlow\WorkFlow;
25 29 use BitCode\BitForm\Frontend\Form\View\FormViewer;
26 30 use BitCode\BitForm\GlobalHelper;
@@ -28,8 +32,11 @@
28 32
29 33 final class FrontendFormManager extends FormManager
30 34 {
31 35 private $_form_identifier;
36 +
37 + /** @var array|null reCAPTCHA v3 verdict, reused for the log */
38 + private $recaptchaV3Verification;
32 39 private $_form_token;
33 40 private $_form_id;
34 41 private $_conf_messages;
35 42 private static $_instance = [];
@@ -77,9 +84,9 @@
77 84 // unset($submitted_data['bit-form-submit-btn']);
78 85 return array_keys($submitted_data);
79 86 }
80 87
81 - public function formView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null)
88 + public function formView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null, $isEntryEdit = false)
82 89 {
83 90 $formContents = $this->getFormContent();
84 91 $formAtomicClsMap = $this->getAtomicClsMap();
85 92 if (!empty($fields)) {
@@ -92,14 +99,14 @@
92 99 );
93 100 $formContents->fields = empty($workFlowreturnedOnLoad['fields']) ? $formContents->fields : $workFlowreturnedOnLoad['fields'];
94 101 }
95 102 $formViewer = new FormViewer($this, $formContents, $formAtomicClsMap, $errorMessages, $previousValue);
96 - $isRestricted = $this->checkSubmissionRestriction(false);
103 + $isRestricted = $this->checkSubmissionRestriction(false, $isEntryEdit);
97 104 $msg = !empty($isRestricted) ? $isRestricted[0] : '';
98 105 return $formViewer->getView($hasFile, $msg);
99 106 }
100 107
101 - public function conversationalFormView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null)
108 + public function conversationalFormView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null, $isEntryEdit = false)
102 109 {
103 110 $formContents = $this->getFormContent();
104 111 $formAtomicClsMap = $this->getAtomicClsMap();
105 112 if (!empty($fields)) {
@@ -112,14 +119,14 @@
112 119 );
113 120 $formContents->fields = empty($workFlowreturnedOnLoad['fields']) ? $formContents->fields : $workFlowreturnedOnLoad['fields'];
114 121 }
115 122 $formViewer = new FormViewer($this, $formContents, $formAtomicClsMap, $errorMessages, $previousValue);
116 - $isRestricted = $this->checkSubmissionRestriction(false);
123 + $isRestricted = $this->checkSubmissionRestriction(false, $isEntryEdit);
117 124 $msg = !empty($isRestricted) ? $isRestricted[0] : '';
118 125 return $formViewer->getConversationalView($hasFile, $msg);
119 126 }
120 127
121 - public function checkEmptySubmission($data, $file)
128 + public function checkEmptySubmission($data, $file, $isEntryEdit = false)
122 129 {
123 130 $formFields = $this->getFields();
124 131 foreach ($formFields as $key => $field) {
125 132 $fieldType = $field['type'];
@@ -130,8 +137,16 @@
130 137 if ('decision-box' === $fieldType || 'gdpr' === $fieldType) {
131 138 continue;
132 139 }
133 140 $isFileType = in_array($fieldType, $fileUploadFieldTypes);
141 + // An edit keeps an untouched file/signature as `<fieldKey>_old`, not as an upload.
142 + if (
143 + $isEntryEdit
144 + && ($isFileType || 'signature' === $fieldType)
145 + && !empty(FieldValueHandler::retainedOldValues($data, $key))
146 + ) {
147 + return false;
148 + }
134 149 if ($this->isRepeatedField($key)) {
135 150 $fileData = !empty($file[$key]) ? $file[$key] : [];
136 151 $dataVal = !empty($data[$key]) ? $data[$key] : [];
137 152 if (!$this->checkRepeatedFieldEmptySubmission($isFileType, $dataVal, $fileData)) {
@@ -178,13 +193,10 @@
178 193 {
179 194 $url = wp_parse_url(wp_get_referer());
180 195 $parameter = [];
181 196 if (isset($url['query'])) {
182 - $queries = explode('&', $url['query']);
183 - foreach ($queries as $query) {
184 - list($field, $value) = explode('=', $query);
185 - $parameter[$field] = $value;
186 - }
197 + // a bare key (?preview) made the old explode() split warn; this also URL-decodes values
198 + wp_parse_str($url['query'], $parameter);
187 199 }
188 200 return $parameter;
189 201 }
190 202
@@ -219,8 +231,119 @@
219 231
220 232 return $post;
221 233 }
222 234
235 + /**
236 + * WP auth errors carry markup and the confirmation box paints them with innerHTML,
237 + * so esc_html() would show the tags as text. kses keeps only the safe markup.
238 + *
239 + * @param mixed $message
240 + *
241 + * @return string
242 + */
243 + private static function authErrorMessage($message)
244 + {
245 + return wp_kses(is_string($message) ? $message : '', EscapingHelper::getAllowedHtmlTags());
246 + }
247 +
248 + /**
249 + * A confirm-enabled email/password field posts as one composite and the validator collapses it
250 + * to the primary value, so the confirm child's own field key never reaches $_POST. WP auth
251 + * integrations map fields by key, so fill those child keys on a copy for the auth filter.
252 + *
253 + * @param mixed $postData
254 + *
255 + * @return mixed
256 + */
257 + private function resolveConfirmChildValues($postData)
258 + {
259 + if (!is_array($postData)) {
260 + return $postData;
261 + }
262 + $fields = $this->getFields();
263 + $postData = $this->resolveCompositeChildValues($postData, $fields);
264 + foreach ($fields as $fieldKey => $fieldData) {
265 + if (
266 + empty($fieldData['childFields'])
267 + || !isset($fieldData['type'])
268 + || !in_array($fieldData['type'], ['email', 'password'], true)
269 + || !empty($fieldData['repeated'])
270 + || !isset($postData[$fieldKey])
271 + ) {
272 + continue;
273 + }
274 + $parentValue = $postData[$fieldKey];
275 + foreach ((array) $fieldData['childFields'] as $childFieldRef) {
276 + $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : '';
277 + if (
278 + empty($childKey)
279 + || !isset($fields[$childKey])
280 + || !empty($fields[$childKey]['isDeactive'])
281 + || isset($postData[$childKey])
282 + ) {
283 + continue;
284 + }
285 + if (is_array($parentValue)) {
286 + if (array_key_exists('confirm', $parentValue)) {
287 + $postData[$childKey] = $parentValue['confirm'];
288 + }
289 + continue;
290 + }
291 + // Validation matched primary against confirm before collapsing, so this is that value.
292 + $postData[$childKey] = $parentValue;
293 + }
294 + if (is_array($parentValue) && array_key_exists('primary', $parentValue)) {
295 + $postData[$fieldKey] = $parentValue['primary'];
296 + }
297 + }
298 +
299 + return $postData;
300 + }
301 +
302 + /**
303 + * Name/Address parts post as one array under the parent key ("parent[first_name]"), so a WP
304 + * auth mapping that points at a part's own key (First Name, Last Name) found nothing. Copy each
305 + * part's value to its key; the parent value is left as it was.
306 + *
307 + * @param array $postData
308 + * @param array $fields getFields() output
309 + *
310 + * @return array
311 + */
312 + private function resolveCompositeChildValues($postData, $fields)
313 + {
314 + foreach ($fields as $fieldKey => $fieldData) {
315 + if (
316 + empty($fieldData['childFields'])
317 + || !isset($fieldData['type'])
318 + || !in_array($fieldData['type'], ['name', 'address'], true)
319 + || !empty($fieldData['repeated'])
320 + || !isset($postData[$fieldKey])
321 + || !is_array($postData[$fieldKey])
322 + ) {
323 + continue;
324 + }
325 + foreach ((array) $fieldData['childFields'] as $childFieldRef) {
326 + $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : '';
327 + if (
328 + empty($childKey)
329 + || !isset($fields[$childKey])
330 + || !empty($fields[$childKey]['isDeactive'])
331 + || isset($postData[$childKey])
332 + || !preg_match('/\[([^\]]+)\]$/', (string) ($fields[$childKey]['name'] ?? ''), $matches)
333 + ) {
334 + continue;
335 + }
336 + $value = FieldValueHandler::extractChildValueFromParentValue($postData[$fieldKey], $matches[1], $childKey);
337 + if (null !== $value) {
338 + $postData[$childKey] = $value;
339 + }
340 + }
341 + }
342 +
343 + return $postData;
344 + }
345 +
223 346 public function handleSubmission()
224 347 {
225 348 // CSRF verified via verifySubmissionNonce() before this method is called. All $_POST reads below occur after that verification.
226 349 $this->fieldNameReplaceOfPost();
@@ -230,9 +353,9 @@
230 353 $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id);
231 354
232 355 if (true === $validated) {
233 356 do_action('bitform_validation_success', $this->_form_id);
234 - unset($_POST['hidden_fields']);
357 + $this->discardHiddenFieldValues();
235 358
236 359 $redirectPage = '';
237 360 $regSuccMsg = '';
238 361
@@ -242,17 +365,18 @@
242 365 $parameter = $this->getParams();
243 366 $existAuthFilter = has_filter('bitform_wp_user_auth');
244 367
245 368 if (true === $existAuthFilter) {
246 - $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $unslashed_post, $parameter);
369 + $authPostData = $this->resolveConfirmChildValues($unslashed_post);
370 + $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter);
247 371
248 - $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $unslashed_post, $parameter);
372 + $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter);
249 373
250 - do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $unslashed_post, $parameter);
374 + do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter);
251 375
252 376 if (isset($result['auth_type']) && 'register' === $result['auth_type']) {
253 377 if (!$result['success']) {
254 - return new WP_Error('errors', esc_html($result['message']));
378 + return new WP_Error('errors', self::authErrorMessage($result['message']));
255 379 } elseif (isset($result['success'])) {
256 380 $redirectPage = $result['redirectPage'];
257 381 $regSuccMsg = $result['message'];
258 382 }
@@ -257,9 +381,9 @@
257 381 $regSuccMsg = $result['message'];
258 382 }
259 383 } else {
260 384 if (!$result['success']) {
261 - return new WP_Error('errors', esc_html($result['message']));
385 + return new WP_Error('errors', self::authErrorMessage($result['message']));
262 386 } else {
263 387 return $result;
264 388 }
265 389 }
@@ -277,38 +401,9 @@
277 401 $newPost = $this->transformDrpdwnValue($unslashed_post);
278 402 $filesData = GlobalHelper::sanitize_files_input($_FILES);
279 403 do_action('bitform_submit_success', $this->_form_id, $entryID, $newPost, $filesData);
280 404
281 - $captchaV3Settings = $this->getCaptchaV3Settings();
282 - if ($captchaV3Settings) {
283 - $token = isset($_POST['g-recaptcha-response']) ? sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])) : '';
284 - $integrationHandler = new IntegrationHandler(0);
285 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', 'gReCaptchaV3');
286 - if (!is_wp_error($allFormIntegrations)) {
287 - foreach ($allFormIntegrations as $integration) {
288 - if (!is_null($integration->integration_type) && 'gReCaptchaV3' === $integration->integration_type) {
289 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
290 - if ($integrationDetails) {
291 - $integrationDetails->id = $integration->id;
292 - $reCAPTCHA = $integrationDetails;
293 - }
294 - }
295 - }
296 - }
297 - if (!empty($reCAPTCHA->secretKey)) {
298 - $gRecaptchaResponse = HttpHelper::post(
299 - 'https://www.google.com/recaptcha/api/siteverify',
300 - ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
301 - );
302 - if ($captchaV3Settings && !empty($saveResponse['triggerData'])) {
303 - $logID = $saveResponse['triggerData']['logID'];
304 - $integId = $reCAPTCHA->id;
305 - $saveApiResponse = new UtilApiResponse();
306 - $saveApiResponse->apiResponse($logID, $integId, ['type_name' => 'ReCaptcha', 'type' => 'v3'], 'success', $gRecaptchaResponse);
307 - }
308 - }
309 - unset($_POST['g-recaptcha-response']);
310 - }
405 + $this->logRecaptchaV3Verification($saveResponse);
311 406 if (!empty($redirectPage) && empty($saveResponse['redirectPage']) || null === $saveResponse['redirectPage']) {
312 407 $saveResponse['redirectPage'] = $redirectPage;
313 408 }
314 409 if (!empty($regSuccMsg) && isset($saveResponse['dflt_message'])) {
@@ -331,9 +426,9 @@
331 426 public function handleUpdateEntry()
332 427 {
333 428 // Entry token or capability verified by caller (FrontendAjax::update_entry). All $_POST reads occur after that check.
334 429 $this->fieldNameReplaceOfPost();
335 - $validated = $this->beforeSubmittedValidate();
430 + $validated = $this->beforeSubmittedValidate(true, true);
336 431 $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id);
337 432
338 433 $entryID = isset($_REQUEST['entryID']) ? sanitize_text_field(wp_unslash($_REQUEST['entryID'])) : null;
339 434 $GLOBALS['bitform_entry_id'] = $entryID;
@@ -341,9 +436,10 @@
341 436 return new WP_Error('empty_form', __('Entries id is invalid', 'bit-form'));
342 437 }
343 438 if (true === $validated) {
344 439 do_action('bitform_validation_success', $this->_form_id);
345 - unset($_POST['hidden_fields'], $_POST['entryID']);
440 + $this->discardHiddenFieldValues();
441 + unset($_POST['entryID']);
346 442
347 443 $redirectPage = '';
348 444 $regSuccMsg = '';
349 445 $postData = wp_unslash($_POST);
@@ -353,13 +449,14 @@
353 449 $parameter = $this->getParams();
354 450 $existAuthFilter = has_filter('bitform_wp_user_auth');
355 451
356 452 if (true === $existAuthFilter) {
357 - $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $postData, $parameter);
453 + $authPostData = $this->resolveConfirmChildValues($postData);
454 + $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter);
358 455
359 456 if (isset($result['auth_type']) && 'register' === $result['auth_type']) {
360 457 if (!$result['success']) {
361 - return new WP_Error('errors', esc_html($result['message']));
458 + return new WP_Error('errors', self::authErrorMessage($result['message']));
362 459 } elseif (isset($result['success'])) {
363 460 $redirectPage = $result['redirectPage'];
364 461 $regSuccMsg = $result['message'];
365 462 }
@@ -364,9 +461,9 @@
364 461 $regSuccMsg = $result['message'];
365 462 }
366 463 } else {
367 464 if (!$result['success']) {
368 - return new WP_Error('errors', esc_html($result['message']));
465 + return new WP_Error('errors', self::authErrorMessage($result['message']));
369 466 } else {
370 467 return $result;
371 468 }
372 469 }
@@ -385,38 +482,9 @@
385 482 //TO DO:: submit success action temporarily added for solution of a issue
386 483 do_action('bitform_submit_success', $this->_form_id, $entryID, $newPost, $filesData);
387 484 do_action('bitform_update_success', $this->_form_id, $entryID, $newPost, $filesData);
388 485
389 - $captchaV3Settings = $this->getCaptchaV3Settings();
390 - if ($captchaV3Settings) {
391 - $token = isset($_POST['g-recaptcha-response']) ? sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])) : '';
392 - $integrationHandler = new IntegrationHandler(0);
393 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', 'gReCaptchaV3');
394 - if (!is_wp_error($allFormIntegrations)) {
395 - foreach ($allFormIntegrations as $integration) {
396 - if (!is_null($integration->integration_type) && 'gReCaptchaV3' === $integration->integration_type) {
397 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
398 - if ($integrationDetails) {
399 - $integrationDetails->id = $integration->id;
400 - $reCAPTCHA = $integrationDetails;
401 - }
402 - }
403 - }
404 - }
405 - if (!empty($reCAPTCHA->secretKey)) {
406 - $gRecaptchaResponse = HttpHelper::post(
407 - 'https://www.google.com/recaptcha/api/siteverify',
408 - ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
409 - );
410 - if ($captchaV3Settings && !empty($updateResponse['triggerData'])) {
411 - $logID = $updateResponse['triggerData']['logID'];
412 - $integId = $reCAPTCHA->id;
413 - $saveApiResponse = new UtilApiResponse();
414 - $saveApiResponse->apiResponse($logID, $integId, ['type_name' => 'ReCaptcha', 'type' => 'v3'], 'success', $gRecaptchaResponse);
415 - }
416 - }
417 - unset($_POST['g-recaptcha-response']);
418 - }
486 + $this->logRecaptchaV3Verification($updateResponse);
419 487 if (!empty($redirectPage) && empty($updateResponse['redirectPage']) || null === $updateResponse['redirectPage']) {
420 488 $updateResponse['redirectPage'] = $redirectPage;
421 489 }
422 490 if (!empty($regSuccMsg) && isset($updateResponse['dflt_message'])) {
@@ -435,11 +503,96 @@
435 503 do_action('bitform_validation_error', $this->_form_id, $validated);
436 504 return $validated;
437 505 }
438 506
507 + /**
508 + * Drop the posted `hidden_fields` transport key and, when the form opts in, the values of
509 + * the fields it names.
510 + *
511 + * A hidden field keeps its typed value in the DOM, so the browser still submits it. Runs
512 + * here because it is the last point before entry, notifications and integrations are built
513 + * from $_POST.
514 + *
515 + * @return void
516 + */
517 + private function discardHiddenFieldValues()
518 + {
519 + // CSRF verified upstream via verifySubmissionNonce(); $_POST is only being narrowed here.
520 + $rawHiddenFields = isset($_POST['hidden_fields']) ? wp_unslash($_POST['hidden_fields']) : '';
521 + unset($_POST['hidden_fields']);
522 +
523 + if (!$this->shouldDiscardHiddenFieldValues()) {
524 + return;
525 + }
526 + $hiddenFieldKeys = FrontendHelpers::parseHiddenFieldKeys($rawHiddenFields);
527 + if (empty($hiddenFieldKeys)) {
528 + return;
529 + }
530 +
531 + $formFields = $this->getFields();
532 + foreach ($hiddenFieldKeys as $fieldKey) {
533 + if (!isset($formFields[$fieldKey])) {
534 + continue;
535 + }
536 + $field = $formFields[$fieldKey];
537 + // The posted list also names builder-hidden and hidden-type fields, which carry a value
538 + // on purpose. Only what conditional logic hid is discarded.
539 + if ('hidden' === $field['type'] || !empty($field['valid']['hide'])) {
540 + continue;
541 + }
542 + // Hiding flags a repeater child once, not per row, so discarding would wipe the column
543 + // in every row.
544 + if (!empty($field['repeated'])) {
545 + continue;
546 + }
547 + // Calculation and tracking fields opt out.
548 + if (!empty($field['valid']['keepValueWhenHidden'])) {
549 + continue;
550 + }
551 + // A composite child (name/address/confirm) posts nested under its parent key.
552 + if (!empty($field['parentFieldKey'])) {
553 + $this->discardCompositeChildValue($formFields, $field, $fieldKey);
554 + continue;
555 + }
556 + unset($_POST[$fieldKey], $_FILES[$fieldKey]);
557 + }
558 + }
559 +
560 + /**
561 + * @param array $formFields
562 + * @param array $field the child field's config
563 + * @param string $fieldKey the child field's key
564 + *
565 + * @return void
566 + */
567 + private function discardCompositeChildValue($formFields, $field, $fieldKey)
568 + {
569 + $parentKey = $field['parentFieldKey'];
570 + if (!isset($_POST[$parentKey]) || !is_array($_POST[$parentKey])) {
571 + return;
572 + }
573 + $parentName = isset($formFields[$parentKey]['name']) ? $formFields[$parentKey]['name'] : '';
574 + $childName = FieldValueHandler::deriveChildName(isset($field['name']) ? $field['name'] : '', $parentName);
575 + unset($_POST[$parentKey][$childName], $_POST[$parentKey][$fieldKey]);
576 + }
577 +
578 + /**
579 + * @return bool
580 + */
581 + private function shouldDiscardHiddenFieldValues()
582 + {
583 + $formInfo = $this->getFormInfo();
584 + if (!is_object($formInfo) || !isset($formInfo->submissionSettings)) {
585 + return false;
586 + }
587 + $submissionSettings = (object) $formInfo->submissionSettings;
588 +
589 + return !empty($submissionSettings->discardHiddenFieldValues);
590 + }
591 +
439 592 public function validateFormSubmission($submitted_data)
440 593 {
441 - $hidden_fields = isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : '';
594 + $hidden_fields = FrontendHelpers::parseHiddenFieldKeys(isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : '');
442 595 $submitted_fields = $this->getSubmittedFields($submitted_data);
443 596 $form_fields = $this->getFields();
444 597 $form_fields_names = array_keys($form_fields);
445 598 if ($this->isGCLIDEnabled()) {
@@ -445,9 +598,9 @@
445 598 if ($this->isGCLIDEnabled()) {
446 599 array_push($form_fields_names, 'GCLID');
447 600 }
448 601 foreach ($submitted_fields as $field) {
449 - if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || false !== strpos($hidden_fields, $field)) {
602 + if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || FrontendHelpers::isFieldHidden($hidden_fields, $field)) {
450 603 unset($submitted_data[$field]);
451 604 }
452 605 }
453 606 return $submitted_data;
@@ -452,13 +605,13 @@
452 605 }
453 606 return $submitted_data;
454 607 }
455 608
456 - public function beforeSubmittedValidate($verifyCaptcha = true)
609 + public function beforeSubmittedValidate($verifyCaptcha = true, $isEntryEdit = false)
457 610 {
458 611 if ($this->verifySubmissionNonce()) {
459 612 if ($this->isExist()) {
460 - $isRestricted = $this->checkSubmissionRestriction();
613 + $isRestricted = $this->checkSubmissionRestriction(true, $isEntryEdit);
461 614 if ($isRestricted && !empty($isRestricted)) {
462 615 return new WP_Error('spam_detection', $isRestricted[0]);
463 616 }
464 617 $postData = wp_unslash($_POST);
@@ -555,9 +708,13 @@
555 708 $form_fields = $step_fields;
556 709 }
557 710 }
558 711 }
559 - $formFieldValidator = new FormFieldValidator($form_fields, $postData, $filesData);
712 + // Only an edit may satisfy a required upload/signature from a `_old` marker.
713 + $editedEntryID = $isEntryEdit && isset($_REQUEST['entryID'])
714 + ? sanitize_text_field(wp_unslash($_REQUEST['entryID']))
715 + : null;
716 + $formFieldValidator = new FormFieldValidator($form_fields, $postData, $filesData, $editedEntryID);
560 717 $validUniuqFields = [];
561 718 $existFilter = has_filter('bitform_check_duplicate_entry');
562 719 if (true === $existFilter) {
563 720 $validUniuqFields = apply_filters('bitform_check_duplicate_entry', $form_fields, $postData);
@@ -593,144 +750,236 @@
593 750 return new WP_Error('token_expired', __('Token expired', 'bit-form'));
594 751 }
595 752 }
596 753
754 + /**
755 + * Whether the IP already has a completed entry; drafts (status 9) don't count.
756 + *
757 + * @param string $ipAddress Visitor IP
758 + *
759 + * @return bool
760 + */
761 + private function hasCompletedEntryFromIp($ipAddress)
762 + {
763 + $storedIp = IpTool::storageValue($ipAddress);
764 + if ('' === $storedIp) {
765 + return false;
766 + }
767 +
768 + $entries = (new FormEntryModel())->get(
769 + ['status'],
770 + [
771 + 'form_id' => $this->form_id,
772 + 'user_ip' => $storedIp,
773 + ]
774 + );
775 +
776 + if (is_wp_error($entries) || !is_array($entries)) {
777 + return false;
778 + }
779 +
780 + foreach ($entries as $entry) {
781 + if (9 !== (int) $entry->status) {
782 + return true;
783 + }
784 + }
785 +
786 + return false;
787 + }
788 +
597 789 private function verifyGRecaptcha()
598 790 {
599 791 $captchaSettings = $this->getCaptchaSettings();
600 792 $captchaV3Settings = $this->getCaptchaV3Settings();
601 - if ($captchaSettings || $captchaV3Settings) {
602 - $token = isset($_POST['g-recaptcha-response']) ? sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])) : '';
603 - if (!isset($_POST['g-recaptcha-response'])) {
604 - return new WP_Error('spam_detection', __('Please recheck your reCaptcha Configuration', 'bit-form'));
793 + if (!$captchaSettings && !$captchaV3Settings) {
794 + return;
795 + }
796 +
797 + if (!isset($_POST['g-recaptcha-response'])) {
798 + return new WP_Error('spam_detection', __('Please recheck your reCaptcha Configuration', 'bit-form'));
799 + }
800 + $token = sanitize_text_field(wp_unslash($_POST['g-recaptcha-response']));
801 +
802 + $appType = $captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3';
803 + $reCAPTCHA = $this->getCaptchaApp($appType);
804 +
805 + // Fail closed: no secret key must not skip verification.
806 + if (empty($reCAPTCHA->secretKey)) {
807 + Log::debug_log("reCAPTCHA ({$appType}) is enabled on form {$this->form_id}, but no secret key is set in App Settings.");
808 + return new WP_Error('spam_detection', __('reCAPTCHA is not configured. Please contact the site administrator.', 'bit-form'));
809 + }
810 +
811 + $gRecaptchaResponse = HttpHelper::post(
812 + 'https://www.google.com/recaptcha/api/siteverify',
813 + ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
814 + );
815 + if (is_wp_error($gRecaptchaResponse)) {
816 + return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form'));
817 + }
818 +
819 + // Tokens are single-use; keep this verdict for the log.
820 + if ('gReCaptchaV3' === $appType) {
821 + $this->recaptchaV3Verification = ['integId' => $reCAPTCHA->id, 'response' => $gRecaptchaResponse];
822 + }
823 +
824 + if ($captchaV3Settings && self::isRecaptchaScoreTooLow($gRecaptchaResponse, $captchaV3Settings)) {
825 + wp_send_json_error(
826 + sanitize_text_field((string) $captchaV3Settings->message)
827 + );
828 + }
829 +
830 + if (!is_object($gRecaptchaResponse) || empty($gRecaptchaResponse->success)) {
831 + return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form'));
832 + }
833 + }
834 +
835 + /**
836 + * Connected captcha app from App Settings, with its integration id.
837 + *
838 + * @param string $appType gReCaptcha, gReCaptchaV3, hcaptcha or turnstileCaptcha
839 + *
840 + * @return object|null
841 + */
842 + private function getCaptchaApp($appType)
843 + {
844 + $apps = (new IntegrationHandler(0))->getAllIntegration('app', $appType);
845 + if (is_wp_error($apps) || !is_array($apps)) {
846 + return null;
847 + }
848 +
849 + $reCAPTCHA = null;
850 + foreach ($apps as $integration) {
851 + if ($appType !== $integration->integration_type) {
852 + continue;
605 853 }
606 - $integrationHandler = new IntegrationHandler(0);
607 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', $captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3');
608 - if (!is_wp_error($allFormIntegrations)) {
609 - foreach ($allFormIntegrations as $integration) {
610 - if (!is_null($integration->integration_type) && $integration->integration_type === ($captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3')) {
611 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
612 - if ($integrationDetails) {
613 - $integrationDetails->id = $integration->id;
614 - $reCAPTCHA = $integrationDetails;
615 - }
616 - }
617 - }
854 + $details = Utilities::jsonObj($integration->integration_details);
855 + if (is_object($details)) {
856 + $details->id = $integration->id;
857 + $reCAPTCHA = $details;
618 858 }
619 - if (!empty($reCAPTCHA->secretKey)) {
620 - $gRecaptchaResponse = HttpHelper::post(
621 - 'https://www.google.com/recaptcha/api/siteverify',
622 - ['secret' => $reCAPTCHA->secretKey, 'response' => $token]
623 - );
624 - $isgReCaptchaVerified = false;
625 - if (!is_wp_error($gRecaptchaResponse)) {
626 - if (
627 - $captchaV3Settings
628 - && !empty($gRecaptchaResponse->score)
629 - && ((float) $gRecaptchaResponse->score < (float) $captchaV3Settings->score)
630 - ) {
631 - wp_send_json_error(
632 - sanitize_text_field((string) $captchaV3Settings->message)
633 - );
634 - }
859 + }
635 860
636 - $isgReCaptchaVerified = $gRecaptchaResponse->success;
637 - }
638 - if (!$isgReCaptchaVerified) {
639 - return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form'));
640 - }
641 - }
861 + return $reCAPTCHA;
862 + }
863 +
864 + /**
865 + * Log the reCAPTCHA v3 verdict for this submission.
866 + *
867 + * @param array $saveResponse Entry save/update response
868 + *
869 + * @return void
870 + */
871 + private function logRecaptchaV3Verification($saveResponse)
872 + {
873 + if (!$this->getCaptchaV3Settings()) {
874 + return;
642 875 }
876 + unset($_POST['g-recaptcha-response']);
877 +
878 + if (empty($this->recaptchaV3Verification) || empty($saveResponse['triggerData']['logID'])) {
879 + return;
880 + }
881 +
882 + $response = $this->recaptchaV3Verification['response'];
883 + $status = is_object($response) && !empty($response->success) ? 'success' : 'error';
884 + (new UtilApiResponse())->apiResponse(
885 + $saveResponse['triggerData']['logID'],
886 + $this->recaptchaV3Verification['integId'],
887 + ['type_name' => 'ReCaptcha', 'type' => 'v3'],
888 + $status,
889 + $response
890 + );
643 891 }
644 892
645 - private function verifyHCaptcha()
893 + /**
894 + * Whether the v3 score is below tolerance; 0.0 counts, blank tolerance means 0.6.
895 + *
896 + * @param mixed $response Decoded siteverify response
897 + * @param mixed $settings Form's recaptchav3 settings
898 + *
899 + * @return bool
900 + */
901 + private static function isRecaptchaScoreTooLow($response, $settings)
646 902 {
647 - $hCaptchaExist = $this->isFieldTypeExist('hcaptcha'); // You can rename this to getHCaptchaSettings() if needed
648 - if ($hCaptchaExist) {
649 - if (!isset($_POST['h-captcha-response'])) {
650 - return new WP_Error('spam_detection', __('Please verify hCaptcha', 'bit-form'));
651 - }
903 + if (!is_object($response) || !isset($response->score) || !is_numeric($response->score)) {
904 + return false;
905 + }
652 906
653 - $token = sanitize_text_field(wp_unslash($_POST['h-captcha-response']));
907 + $threshold = is_object($settings) && isset($settings->score) && is_numeric($settings->score)
908 + ? (float) $settings->score
909 + : 0.6;
654 910
655 - $integrationHandler = new IntegrationHandler(0);
656 - $allFormIntegrations = $integrationHandler->getAllIntegration('app', 'hcaptcha');
911 + return (float) $response->score < $threshold;
912 + }
657 913
658 - if (!is_wp_error($allFormIntegrations)) {
659 - foreach ($allFormIntegrations as $integration) {
660 - if (!is_null($integration->integration_type) && 'hcaptcha' === $integration->integration_type) {
661 - $integrationDetails = Utilities::jsonObj($integration->integration_details);
662 - if ($integrationDetails) {
663 - $integrationDetails->id = $integration->id;
664 - $hCaptcha = $integrationDetails;
665 - }
666 - }
667 - }
668 - }
914 + private function verifyHCaptcha()
915 + {
916 + if (!$this->isFieldTypeExist('hcaptcha')) {
917 + return;
918 + }
669 919
670 - if (!empty($hCaptcha->secretKey)) {
671 - $hCaptchaResponse = HttpHelper::post(
672 - 'https://api.hcaptcha.com/siteverify',
673 - [
674 - 'secret' => $hCaptcha->secretKey,
675 - 'response' => $token,
676 - 'remoteip' => (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '')
677 - ]
678 - );
920 + if (!isset($_POST['h-captcha-response'])) {
921 + return new WP_Error('spam_detection', __('Please verify hCaptcha', 'bit-form'));
922 + }
923 + $token = sanitize_text_field(wp_unslash($_POST['h-captcha-response']));
924 + $hCaptcha = $this->getCaptchaApp('hcaptcha');
679 925
680 - $isVerified = false;
681 - if (!is_wp_error($hCaptchaResponse)) {
682 - $isVerified = $hCaptchaResponse->success;
683 - }
926 + // Fail closed: no secret key must not skip verification.
927 + if (empty($hCaptcha->secretKey)) {
928 + Log::debug_log("hCaptcha is enabled on form {$this->form_id}, but no secret key is set in App Settings.");
929 + return new WP_Error('spam_detection', __('hCaptcha is not configured. Please contact the site administrator.', 'bit-form'));
930 + }
684 931
685 - if (!$isVerified) {
686 - return new WP_Error('spam_detection', __('hCaptcha verification failed', 'bit-form'));
687 - }
688 - }
932 + $hCaptchaResponse = HttpHelper::post(
933 + 'https://api.hcaptcha.com/siteverify',
934 + [
935 + 'secret' => $hCaptcha->secretKey,
936 + 'response' => $token,
937 + 'remoteip' => (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '')
938 + ]
939 + );
940 +
941 + if (is_wp_error($hCaptchaResponse) || !is_object($hCaptchaResponse) || empty($hCaptchaResponse->success)) {
942 + return new WP_Error('spam_detection', __('hCaptcha verification failed', 'bit-form'));
689 943 }
690 944 }
691 945
692 946 private function verifyTurnstileCaptcha()
693 947 {
694 - $turnstileExist = $this->isFieldTypeExist('turnstile');
695 - if ($turnstileExist) {
696 - if (!isset($_POST['cf-turnstile-response'])) {
697 - return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
698 - }
699 - $token = sanitize_text_field(wp_unslash($_POST['cf-turnstile-response']));
700 - $turnstileCaptcha = null;
701 - $integrationHandler = new IntegrationHandler(0);
702 - $turnstileIntegration = $integrationHandler->getAllIntegration('app', 'turnstileCaptcha')[0];
703 - if (!is_wp_error($turnstileIntegration && !is_null($turnstileIntegration->integration_type))) {
704 - $turnstileCaptcha = json_decode($turnstileIntegration->integration_details);
705 - // $integrationDetails->id = $turnstileIntegration->id;
706 - // $turnstileCaptcha = $integrationDetails;
707 - }
708 - if (!is_null($turnstileCaptcha)) {
709 - $isTurnstileCaptchaVerified = false;
710 - $turnstileRecaptchaResponse = HttpHelper::post(
711 - 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
712 - ['secret' => $turnstileCaptcha->secretKey, 'response' => $token]
713 - );
714 - if (!is_wp_error($turnstileRecaptchaResponse)) {
715 - if (!$turnstileRecaptchaResponse->success) {
716 - $errorCodes = implode(', ', (array) ($turnstileRecaptchaResponse->{'error-codes'} ?? []));
717 - wp_send_json_error(
718 - sprintf(
719 - /* translators: %s: dynamic value. */
720 - __('Cloudflare Turnstile Validation Error: %s', 'bit-form'),
721 - $errorCodes
722 - )
723 - );
724 - }
948 + if (!$this->isFieldTypeExist('turnstile')) {
949 + return;
950 + }
725 951
726 - $isTurnstileCaptchaVerified = $turnstileRecaptchaResponse->success;
727 - }
728 - if (!$isTurnstileCaptchaVerified) {
729 - return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
730 - }
731 - }
952 + if (!isset($_POST['cf-turnstile-response'])) {
953 + return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
732 954 }
955 + $token = sanitize_text_field(wp_unslash($_POST['cf-turnstile-response']));
956 + $turnstileCaptcha = $this->getCaptchaApp('turnstileCaptcha');
957 +
958 + // Fail closed: no secret key must not skip verification.
959 + if (empty($turnstileCaptcha->secretKey)) {
960 + Log::debug_log("Turnstile is enabled on form {$this->form_id}, but no secret key is set in App Settings.");
961 + return new WP_Error('spam_detection', __('Cloudflare Turnstile is not configured. Please contact the site administrator.', 'bit-form'));
962 + }
963 +
964 + $turnstileResponse = HttpHelper::post(
965 + 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
966 + ['secret' => $turnstileCaptcha->secretKey, 'response' => $token]
967 + );
968 + if (is_wp_error($turnstileResponse) || !is_object($turnstileResponse)) {
969 + return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form'));
970 + }
971 +
972 + if (empty($turnstileResponse->success)) {
973 + $errorCodes = implode(', ', (array) ($turnstileResponse->{'error-codes'} ?? []));
974 + wp_send_json_error(
975 + sprintf(
976 + /* translators: %s: dynamic value. */
977 + __('Cloudflare Turnstile Validation Error: %s', 'bit-form'),
978 + $errorCodes
979 + )
980 + );
981 + }
733 982 }
734 983
735 984 public function verifySubmissionNonce()
736 985 {
@@ -747,9 +996,9 @@
747 996 {
748 997 if (!current_user_can('manage_options')) {
749 998 $update_status = $this->formModel->update(
750 999 [
751 - 'views' => intval(static::$form[0]->views) + 1
1000 + 'views' => intval($this->form[0]->views) + 1
752 1001 ],
753 1002 [
754 1003 'id' => $this->form_id
755 1004 ]
@@ -756,9 +1005,13 @@
756 1005 );
757 1006 }
758 1007 }
759 1008
760 - public function checkSubmissionRestriction($checkedEmptySubmitted = true)
1009 + /**
1010 + * @param bool $checkedEmptySubmitted whether the empty-submission rule applies here
1011 + * @param bool $isEntryEdit true when an existing entry is being updated
1012 + */
1013 + public function checkSubmissionRestriction($checkedEmptySubmitted = true, $isEntryEdit = false)
761 1014 {
762 1015 $formContents = $this->getFormContent();
763 1016 $additionalSettings = isset($formContents->additional) ? $formContents->additional : null;
764 1017 $fromRestrictionSetitingsEnabled = empty($additionalSettings->enabled) ? [] : $additionalSettings->enabled;
@@ -774,8 +1027,21 @@
774 1027 $currentUserId = get_current_user_id();
775 1028
776 1029 foreach ($fromRestrictionSetitingsEnabled as $restrictionKey => $isEnabled) {
777 1030 if ($isEnabled) {
1031 + // Quota rules gate creating an entry, so an edit skips them; access-control keys stay.
1032 + $skippableOnEdit = ['onePerIp', 'entry_limit', 'entry_limit_by_user', 'restrict_form'];
1033 + if ($isEntryEdit && in_array($restrictionKey, $skippableOnEdit, true)) {
1034 + $skipOnEdit = apply_filters(
1035 + 'bitform_skip_restriction_on_entry_edit',
1036 + true,
1037 + $restrictionKey,
1038 + $this->form_id
1039 + );
1040 + if ($skipOnEdit) {
1041 + continue;
1042 + }
1043 + }
778 1044 /**
779 1045 * Allow add-ons to handle any restriction key (Pro-only restrictions
780 1046 * should be implemented in the add-on, not shipped in the free plugin).
781 1047 *
@@ -797,33 +1063,9 @@
797 1063 continue;
798 1064 }
799 1065
800 1066 if ('onePerIp' === $restrictionKey) {
801 - $formEntry = new FormEntryModel();
802 -
803 - $getResult = $formEntry->get(
804 - ['user_ip', 'status'],
805 - [
806 - 'form_id' => $this->form_id,
807 - 'user_ip' => (int) ip2long((string) $ipAddress)
808 - ],
809 - );
810 -
811 - $count = 0;
812 - $status = 0;
813 -
814 - if (!is_wp_error($getResult) && count($getResult) > 0) {
815 - $count = count($getResult);
816 -
817 - foreach ($getResult as $row) {
818 - if (9 === (int) $row->status) {
819 - $status = 9;
820 - break;
821 - }
822 - }
823 - }
824 -
825 - if ($count > 0 && 9 !== (int) $status) {
1067 + if ($this->hasCompletedEntryFromIp($ipAddress)) {
826 1068 $onePerIp = __('Sorry!! You have already submitted from this IP address', 'bit-form');
827 1069
828 1070 $onePerIp = apply_filters(
829 1071 'bitform_filter_restriction_one_per_ip_message',
@@ -845,9 +1087,9 @@
845 1087
846 1088 $restrictionMessage[] = $is_login_messages;
847 1089 }
848 1090 if ($checkedEmptySubmitted && 'empty_submission' === $restrictionKey) {
849 - $isEmpty = $this->checkEmptySubmission(wp_unslash($_POST), GlobalHelper::sanitize_files_input($_FILES));
1091 + $isEmpty = $this->checkEmptySubmission(wp_unslash($_POST), GlobalHelper::sanitize_files_input($_FILES), $isEntryEdit);
850 1092 if ($isEmpty) {
851 1093 $restriction = $fromRestrictionSetitings->empty_submission->message;
852 1094
853 1095 $restriction = apply_filters(