_form_identifier = 'bitforms_' . $form_id; $this->_form_identifier .= !empty(get_post()->ID) ? '_' . get_post()->ID : ''; $this->_form_identifier .= !empty($shortCodeCounter) ? "_$shortCodeCounter" : ''; $this->_form_token = wp_create_nonce('bitforms_' . $form_id); $this->_form_id = $form_id; } public static function getInstance($form_id, $shortCodeCounter = null) { $key = $form_id . ':' . ($shortCodeCounter ?? 'default'); if (!isset(self::$_instance[$key])) { self::$_instance[$key] = new self($form_id, $shortCodeCounter); } return self::$_instance[$key]; } public function getFormIdentifier() { return $this->_form_identifier; } public function getFormID() { return $this->_form_id; } public function getFormToken() { return $this->_form_token; } public function getSubmittedFields($submitted_data) { unset($submitted_data[$this->_form_identifier]); // unset($submitted_data['bit-form-submit-btn']); return array_keys($submitted_data); } public function formView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null, $isEntryEdit = false) { $formContents = $this->getFormContent(); $formAtomicClsMap = $this->getAtomicClsMap(); if (!empty($fields)) { $formContents->fields = is_string($fields) ? json_decode($fields) : $fields; } else { $workFlowRunHelper = new WorkFlow($this->form_id); $workFlowreturnedOnLoad = $workFlowRunHelper->executeOnLoad( 'create', $formContents->fields ); $formContents->fields = empty($workFlowreturnedOnLoad['fields']) ? $formContents->fields : $workFlowreturnedOnLoad['fields']; } $formViewer = new FormViewer($this, $formContents, $formAtomicClsMap, $errorMessages, $previousValue); $isRestricted = $this->checkSubmissionRestriction(false, $isEntryEdit); $msg = !empty($isRestricted) ? $isRestricted[0] : ''; return $formViewer->getView($hasFile, $msg); } public function conversationalFormView($fields = null, $hasFile = false, $errorMessages = null, $previousValue = null, $isEntryEdit = false) { $formContents = $this->getFormContent(); $formAtomicClsMap = $this->getAtomicClsMap(); if (!empty($fields)) { $formContents->fields = is_string($fields) ? json_decode($fields) : $fields; } else { $workFlowRunHelper = new WorkFlow($this->form_id); $workFlowreturnedOnLoad = $workFlowRunHelper->executeOnLoad( 'create', $formContents->fields ); $formContents->fields = empty($workFlowreturnedOnLoad['fields']) ? $formContents->fields : $workFlowreturnedOnLoad['fields']; } $formViewer = new FormViewer($this, $formContents, $formAtomicClsMap, $errorMessages, $previousValue); $isRestricted = $this->checkSubmissionRestriction(false, $isEntryEdit); $msg = !empty($isRestricted) ? $isRestricted[0] : ''; return $formViewer->getConversationalView($hasFile, $msg); } public function checkEmptySubmission($data, $file, $isEntryEdit = false) { $formFields = $this->getFields(); foreach ($formFields as $key => $field) { $fieldType = $field['type']; if ('button' === $fieldType) { continue; } $fileUploadFieldTypes = ['file-up', 'advanced-file-up']; if ('decision-box' === $fieldType || 'gdpr' === $fieldType) { continue; } $isFileType = in_array($fieldType, $fileUploadFieldTypes); // An edit keeps an untouched file/signature as `_old`, not as an upload. if ( $isEntryEdit && ($isFileType || 'signature' === $fieldType) && !empty(FieldValueHandler::retainedOldValues($data, $key)) ) { return false; } if ($this->isRepeatedField($key)) { $fileData = !empty($file[$key]) ? $file[$key] : []; $dataVal = !empty($data[$key]) ? $data[$key] : []; if (!$this->checkRepeatedFieldEmptySubmission($isFileType, $dataVal, $fileData)) { return false; } continue; } if (!$isFileType && (!empty($data[$key]) || (isset($data[$key]) && is_numeric($data[$key])))) { return false; } if ($isFileType && !empty($file[$key]['name']) && is_string($file[$key]['name'])) { return false; } if ($isFileType && !empty($file[$key]['name'][0])) { return false; } } return true; } private function checkRepeatedFieldEmptySubmission($isFileType, $data, $file = []) { if (!$isFileType) { foreach ($data as $value) { if (!empty($value)) { return false; } } } if ($isFileType) { foreach ($file['name'] as $value) { if (!empty($value) && is_string($value)) { return false; } if (is_array($value) && !empty($value[0])) { return false; } } } return true; } private function getParams() { $url = wp_parse_url(wp_get_referer()); $parameter = []; if (isset($url['query'])) { // a bare key (?preview) made the old explode() split warn; this also URL-decodes values wp_parse_str($url['query'], $parameter); } return $parameter; } private function getFormFields($formID) { $adminFormHandler = new AdminFormHandler(); $post = new \stdClass(); $post = (object) [ 'id' => $formID ]; $getForm = $adminFormHandler->getAForm('', $post); $formContainer = $getForm['form_content']; return $formContainer['fields']; } private function transformDrpdwnValue($post) { $formFields = $this->getFormFields($this->_form_id); foreach ($post as $key => $value) { if (!str_starts_with($key, 'repeater') && isset($formFields->{$key}) && 'select' === $formFields->{$key}->typ) { if (is_array($value)) { foreach ($value as $k => $v) { $post[$key][$k] = !is_array($v) && is_string($v) ? explode(BITFORMS_BF_SEPARATOR, $v) : $v; } } else { $post[$key] = explode(BITFORMS_BF_SEPARATOR, $value); } }; } return $post; } /** * WP auth errors carry markup and the confirmation box paints them with innerHTML, * so esc_html() would show the tags as text. kses keeps only the safe markup. * * @param mixed $message * * @return string */ private static function authErrorMessage($message) { return wp_kses(is_string($message) ? $message : '', EscapingHelper::getAllowedHtmlTags()); } /** * A confirm-enabled email/password field posts as one composite and the validator collapses it * to the primary value, so the confirm child's own field key never reaches $_POST. WP auth * integrations map fields by key, so fill those child keys on a copy for the auth filter. * * @param mixed $postData * * @return mixed */ private function resolveConfirmChildValues($postData) { if (!is_array($postData)) { return $postData; } $fields = $this->getFields(); $postData = $this->resolveCompositeChildValues($postData, $fields); foreach ($fields as $fieldKey => $fieldData) { if ( empty($fieldData['childFields']) || !isset($fieldData['type']) || !in_array($fieldData['type'], ['email', 'password'], true) || !empty($fieldData['repeated']) || !isset($postData[$fieldKey]) ) { continue; } $parentValue = $postData[$fieldKey]; foreach ((array) $fieldData['childFields'] as $childFieldRef) { $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : ''; if ( empty($childKey) || !isset($fields[$childKey]) || !empty($fields[$childKey]['isDeactive']) || isset($postData[$childKey]) ) { continue; } if (is_array($parentValue)) { if (array_key_exists('confirm', $parentValue)) { $postData[$childKey] = $parentValue['confirm']; } continue; } // Validation matched primary against confirm before collapsing, so this is that value. $postData[$childKey] = $parentValue; } if (is_array($parentValue) && array_key_exists('primary', $parentValue)) { $postData[$fieldKey] = $parentValue['primary']; } } return $postData; } /** * Name/Address parts post as one array under the parent key ("parent[first_name]"), so a WP * auth mapping that points at a part's own key (First Name, Last Name) found nothing. Copy each * part's value to its key; the parent value is left as it was. * * @param array $postData * @param array $fields getFields() output * * @return array */ private function resolveCompositeChildValues($postData, $fields) { foreach ($fields as $fieldKey => $fieldData) { if ( empty($fieldData['childFields']) || !isset($fieldData['type']) || !in_array($fieldData['type'], ['name', 'address'], true) || !empty($fieldData['repeated']) || !isset($postData[$fieldKey]) || !is_array($postData[$fieldKey]) ) { continue; } foreach ((array) $fieldData['childFields'] as $childFieldRef) { $childKey = is_object($childFieldRef) && isset($childFieldRef->fldKey) ? $childFieldRef->fldKey : ''; if ( empty($childKey) || !isset($fields[$childKey]) || !empty($fields[$childKey]['isDeactive']) || isset($postData[$childKey]) || !preg_match('/\[([^\]]+)\]$/', (string) ($fields[$childKey]['name'] ?? ''), $matches) ) { continue; } $value = FieldValueHandler::extractChildValueFromParentValue($postData[$fieldKey], $matches[1], $childKey); if (null !== $value) { $postData[$childKey] = $value; } } } return $postData; } public function handleSubmission() { // CSRF verified via verifySubmissionNonce() before this method is called. All $_POST reads below occur after that verification. $this->fieldNameReplaceOfPost(); $validated = $this->beforeSubmittedValidate(); $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id); if (true === $validated) { do_action('bitform_validation_success', $this->_form_id); $this->discardHiddenFieldValues(); $redirectPage = ''; $regSuccMsg = ''; $existAuth = (new IntegrationHandler($this->_form_id))->getAllIntegration('wp_user_auth', 'wp_auth', 1); $unslashed_post = wp_unslash($_POST); if (!is_wp_error($existAuth) && count($existAuth) > 0) { $parameter = $this->getParams(); $existAuthFilter = has_filter('bitform_wp_user_auth'); if (true === $existAuthFilter) { $authPostData = $this->resolveConfirmChildValues($unslashed_post); $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter); $result = apply_filters('bitform_filter_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter); do_action('bitform_wp_user_auth_response', $result, $this->_form_id, $authPostData, $parameter); if (isset($result['auth_type']) && 'register' === $result['auth_type']) { if (!$result['success']) { return new WP_Error('errors', self::authErrorMessage($result['message'])); } elseif (isset($result['success'])) { $redirectPage = $result['redirectPage']; $regSuccMsg = $result['message']; } } else { if (!$result['success']) { return new WP_Error('errors', self::authErrorMessage($result['message'])); } else { return $result; } } } } $saveResponse = $this->saveFormEntry($unslashed_post); if (is_wp_error($saveResponse)) { return $saveResponse; } $entryID = $saveResponse['entry_id']; // transformed dropdown value from string to array $newPost = $this->transformDrpdwnValue($unslashed_post); $filesData = GlobalHelper::sanitize_files_input($_FILES); do_action('bitform_submit_success', $this->_form_id, $entryID, $newPost, $filesData); $this->logRecaptchaV3Verification($saveResponse); if (!empty($redirectPage) && empty($saveResponse['redirectPage']) || null === $saveResponse['redirectPage']) { $saveResponse['redirectPage'] = $redirectPage; } if (!empty($regSuccMsg) && isset($saveResponse['dflt_message'])) { $saveResponse['message'] = $regSuccMsg; } $saveResponse['new_nonce'] = wp_create_nonce('bitforms_' . $this->_form_id); $saveResponse = IntegrationHandler::maybeSetCronForIntegration($saveResponse, 'create'); $entryId = $saveResponse['entry_id']; $responseMsg = is_array($saveResponse) && !empty($saveResponse) ? $saveResponse : __('Form Submitted Successfully', 'bit-form'); $_POST = []; $responseMsg['entry_id'] = $entryId; return $responseMsg; } do_action('bitform_validation_error', $this->_form_id, $validated); return $validated; } public function handleUpdateEntry() { // Entry token or capability verified by caller (FrontendAjax::update_entry). All $_POST reads occur after that check. $this->fieldNameReplaceOfPost(); $validated = $this->beforeSubmittedValidate(true, true); $validated = apply_filters('bitform_filter_form_validation', $validated, $this->_form_id); $entryID = isset($_REQUEST['entryID']) ? sanitize_text_field(wp_unslash($_REQUEST['entryID'])) : null; $GLOBALS['bitform_entry_id'] = $entryID; if (is_null($entryID)) { return new WP_Error('empty_form', __('Entries id is invalid', 'bit-form')); } if (true === $validated) { do_action('bitform_validation_success', $this->_form_id); $this->discardHiddenFieldValues(); unset($_POST['entryID']); $redirectPage = ''; $regSuccMsg = ''; $postData = wp_unslash($_POST); $existAuth = (new IntegrationHandler($this->_form_id))->getAllIntegration('wp_user_auth', 'wp_auth', 1); if (!is_wp_error($existAuth) && count($existAuth) > 0) { $parameter = $this->getParams(); $existAuthFilter = has_filter('bitform_wp_user_auth'); if (true === $existAuthFilter) { $authPostData = $this->resolveConfirmChildValues($postData); $result = apply_filters('bitform_wp_user_auth', $existAuth[0], $authPostData, $parameter); if (isset($result['auth_type']) && 'register' === $result['auth_type']) { if (!$result['success']) { return new WP_Error('errors', self::authErrorMessage($result['message'])); } elseif (isset($result['success'])) { $redirectPage = $result['redirectPage']; $regSuccMsg = $result['message']; } } else { if (!$result['success']) { return new WP_Error('errors', self::authErrorMessage($result['message'])); } else { return $result; } } } } $updateResponse = $this->updateFormEntry(wp_unslash($_POST), $this->getFormID(), $entryID); if (is_wp_error($updateResponse)) { return $updateResponse; } // transformed dropdown value from string to array $newPost = $this->transformDrpdwnValue($postData); $filesData = GlobalHelper::sanitize_files_input($_FILES); //TO DO:: submit success action temporarily added for solution of a issue do_action('bitform_submit_success', $this->_form_id, $entryID, $newPost, $filesData); do_action('bitform_update_success', $this->_form_id, $entryID, $newPost, $filesData); $this->logRecaptchaV3Verification($updateResponse); if (!empty($redirectPage) && empty($updateResponse['redirectPage']) || null === $updateResponse['redirectPage']) { $updateResponse['redirectPage'] = $redirectPage; } if (!empty($regSuccMsg) && isset($updateResponse['dflt_message'])) { $updateResponse['message'] = $regSuccMsg; } $updateResponse['new_nonce'] = wp_create_nonce('bitforms_' . $this->_form_id); $updateResponse = IntegrationHandler::maybeSetCronForIntegration($updateResponse, 'update'); $entryId = $updateResponse['entry_id']; $responseMsg = is_array($updateResponse) && !empty($updateResponse) ? $updateResponse : __('Entry Update Successfully', 'bit-form'); $_POST = []; $responseMsg['entry_id'] = $entryId; return $responseMsg; } do_action('bitform_validation_error', $this->_form_id, $validated); return $validated; } /** * Drop the posted `hidden_fields` transport key and, when the form opts in, the values of * the fields it names. * * A hidden field keeps its typed value in the DOM, so the browser still submits it. Runs * here because it is the last point before entry, notifications and integrations are built * from $_POST. * * @return void */ private function discardHiddenFieldValues() { // CSRF verified upstream via verifySubmissionNonce(); $_POST is only being narrowed here. $rawHiddenFields = isset($_POST['hidden_fields']) ? wp_unslash($_POST['hidden_fields']) : ''; unset($_POST['hidden_fields']); if (!$this->shouldDiscardHiddenFieldValues()) { return; } $hiddenFieldKeys = FrontendHelpers::parseHiddenFieldKeys($rawHiddenFields); if (empty($hiddenFieldKeys)) { return; } $formFields = $this->getFields(); foreach ($hiddenFieldKeys as $fieldKey) { if (!isset($formFields[$fieldKey])) { continue; } $field = $formFields[$fieldKey]; // The posted list also names builder-hidden and hidden-type fields, which carry a value // on purpose. Only what conditional logic hid is discarded. if ('hidden' === $field['type'] || !empty($field['valid']['hide'])) { continue; } // Hiding flags a repeater child once, not per row, so discarding would wipe the column // in every row. if (!empty($field['repeated'])) { continue; } // Calculation and tracking fields opt out. if (!empty($field['valid']['keepValueWhenHidden'])) { continue; } // A composite child (name/address/confirm) posts nested under its parent key. if (!empty($field['parentFieldKey'])) { $this->discardCompositeChildValue($formFields, $field, $fieldKey); continue; } unset($_POST[$fieldKey], $_FILES[$fieldKey]); } } /** * @param array $formFields * @param array $field the child field's config * @param string $fieldKey the child field's key * * @return void */ private function discardCompositeChildValue($formFields, $field, $fieldKey) { $parentKey = $field['parentFieldKey']; if (!isset($_POST[$parentKey]) || !is_array($_POST[$parentKey])) { return; } $parentName = isset($formFields[$parentKey]['name']) ? $formFields[$parentKey]['name'] : ''; $childName = FieldValueHandler::deriveChildName(isset($field['name']) ? $field['name'] : '', $parentName); unset($_POST[$parentKey][$childName], $_POST[$parentKey][$fieldKey]); } /** * @return bool */ private function shouldDiscardHiddenFieldValues() { $formInfo = $this->getFormInfo(); if (!is_object($formInfo) || !isset($formInfo->submissionSettings)) { return false; } $submissionSettings = (object) $formInfo->submissionSettings; return !empty($submissionSettings->discardHiddenFieldValues); } public function validateFormSubmission($submitted_data) { $hidden_fields = FrontendHelpers::parseHiddenFieldKeys(isset($submitted_data['hidden_fields']) ? $submitted_data['hidden_fields'] : ''); $submitted_fields = $this->getSubmittedFields($submitted_data); $form_fields = $this->getFields(); $form_fields_names = array_keys($form_fields); if ($this->isGCLIDEnabled()) { array_push($form_fields_names, 'GCLID'); } foreach ($submitted_fields as $field) { if ('hidden_fields' !== $field && !in_array($field, $form_fields_names) || FrontendHelpers::isFieldHidden($hidden_fields, $field)) { unset($submitted_data[$field]); } } return $submitted_data; } public function beforeSubmittedValidate($verifyCaptcha = true, $isEntryEdit = false) { if ($this->verifySubmissionNonce()) { if ($this->isExist()) { $isRestricted = $this->checkSubmissionRestriction(true, $isEntryEdit); if ($isRestricted && !empty($isRestricted)) { return new WP_Error('spam_detection', $isRestricted[0]); } $postData = wp_unslash($_POST); $filesData = GlobalHelper::sanitize_files_input($_FILES); $isHoneypot = apply_filters('bitform_check_honeypot', false, $this->_form_id, $postData); if ($isHoneypot) { return new WP_Error('spam_detection', __('Token verification failed', 'bit-form')); } $formCurrentStep = isset($_POST['form-current-step']) ? sanitize_text_field(wp_unslash($_POST['form-current-step'])) : null; // TODO: Temporary parameter to skip captcha verification in step change of multi step form if ($verifyCaptcha) { $verifyGRecaptchaResult = $this->verifyGRecaptcha(); if (is_wp_error($verifyGRecaptchaResult)) { return $verifyGRecaptchaResult; } $verifyHCaptchaResult = $this->verifyHCaptcha(); if (is_wp_error($verifyHCaptchaResult)) { return $verifyHCaptchaResult; } /* Implement Turnstile Captcha start */ $verifyTurnstileCaptchaResult = $this->verifyTurnstileCaptcha(); if (is_wp_error($verifyTurnstileCaptchaResult)) { return $verifyTurnstileCaptchaResult; } } /* Implement Turnstile Captcha end */ $existAuth = (new IntegrationHandler($this->_form_id))->getAllIntegration('wp_user_auth', 'wp_auth', 1); // check if user is already logged in and form has auth integration do_action('bitform_checked_exist_auth', $this->_form_id, $existAuth); if (!is_wp_error($existAuth) && count($existAuth) > 0 && is_user_logged_in()) { return new WP_Error('auth_error', __('You are already logged in', 'bit-form')); } $validateForm = $this->validateFormSubmission($postData); $validateFormFiles = $this->validateFormSubmission($filesData); $validateForm = array_merge($validateForm, $validateFormFiles); // Validate only provably-rendered fields: a field stranded in form_content->fields // with no layout entry (orphan) is never shown to the user and must not block // submission. getRenderedFields() unions ALL breakpoints × steps × nested layouts // + childFields of rendered parents, derives only from DB-stored form_content, // and fails closed (returns all fields) when the layout is unusable. $form_fields = $this->getRenderedFields(); // check if form-current-step is set and form is multi-step $formCurrentStep = isset($_POST['form-current-step']) ? sanitize_text_field(wp_unslash($_POST['form-current-step'])) : null; if (!is_null($formCurrentStep)) { // Narrow validation to the current step's fields. SECURITY: the step // key set unions ALL breakpoints (lg/md/sm) — an md/sm-only field was // previously null-skipped by the validator (silent bypass). A forged // step index or malformed layout skips the narrowing entirely so every // rendered field stays validated (fail closed). $formContents = $this->getFormContent(); $layout = isset($formContents->layout) ? $formContents->layout : null; $stepIndex = (int) $formCurrentStep - 1; if (is_array($layout) && isset($layout[$stepIndex]->layout) && is_object($layout[$stepIndex]->layout)) { $stepLayout = $layout[$stepIndex]->layout; $nestedLayout = isset($formContents->nestedLayout) && is_object($formContents->nestedLayout) ? $formContents->nestedLayout : null; $stepKeys = []; foreach (['lg', 'md', 'sm'] as $brkpnt) { if (!isset($stepLayout->{$brkpnt}) || !is_array($stepLayout->{$brkpnt})) { continue; } foreach ($stepLayout->{$brkpnt} as $lay) { if (!is_object($lay) || !isset($lay->i)) { continue; } $fk = $lay->i; $stepKeys[$fk] = true; if (!is_null($nestedLayout) && isset($nestedLayout->{$fk})) { foreach (['lg', 'md', 'sm'] as $nBrkpnt) { if (!isset($nestedLayout->{$fk}->{$nBrkpnt}) || !is_array($nestedLayout->{$fk}->{$nBrkpnt})) { continue; } foreach ($nestedLayout->{$fk}->{$nBrkpnt} as $nestedLay) { if (is_object($nestedLay) && isset($nestedLay->i)) { $stepKeys[$nestedLay->i] = true; } } } } } } // Name/Address/Email/Password children live outside layouts; a child // is part of this step iff its parent is. self::expandChildFieldKeys($stepKeys, $form_fields); if (!empty($stepKeys)) { $step_fields = []; foreach (array_keys($stepKeys) as $fk) { if (isset($form_fields[$fk])) { $step_fields[$fk] = $form_fields[$fk]; } } $form_fields = $step_fields; } } } // Only an edit may satisfy a required upload/signature from a `_old` marker. $editedEntryID = $isEntryEdit && isset($_REQUEST['entryID']) ? sanitize_text_field(wp_unslash($_REQUEST['entryID'])) : null; $formFieldValidator = new FormFieldValidator($form_fields, $postData, $filesData, $editedEntryID); $validUniuqFields = []; $existFilter = has_filter('bitform_check_duplicate_entry'); if (true === $existFilter) { $validUniuqFields = apply_filters('bitform_check_duplicate_entry', $form_fields, $postData); $fieldKeys = array_keys($validUniuqFields); $form_fields_keys = array_keys($form_fields); $uniqueFields = []; foreach ($fieldKeys as $key) { if (in_array($key, $form_fields_keys)) { $uniqueFields[] = $form_fields[$key]; } } do_action('bitform_Unique_entry', $uniqueFields, $validUniuqFields, $this->_form_id, $postData); } $validateField = $formFieldValidator->validate('create', $this->_form_id); if ($validateForm && $validateField && 0 === count($validUniuqFields)) { return true; } else { $error = __('Please submit form with valid fields', 'bit-form'); if (!$validateForm) { $errorMessages = $error; } elseif (count($formFieldValidator->getMessage()) > 0) { $errorMessages = $formFieldValidator->getMessage(); } else { $errorMessages = 0 === count($validUniuqFields) ? $error : $validUniuqFields; } return new WP_Error('validation_error', $errorMessages); } } return new WP_Error('unknown_form', __('Form does not exist', 'bit-form')); } else { return new WP_Error('token_expired', __('Token expired', 'bit-form')); } } /** * Whether the IP already has a completed entry; drafts (status 9) don't count. * * @param string $ipAddress Visitor IP * * @return bool */ private function hasCompletedEntryFromIp($ipAddress) { $storedIp = IpTool::storageValue($ipAddress); if ('' === $storedIp) { return false; } $entries = (new FormEntryModel())->get( ['status'], [ 'form_id' => $this->form_id, 'user_ip' => $storedIp, ] ); if (is_wp_error($entries) || !is_array($entries)) { return false; } foreach ($entries as $entry) { if (9 !== (int) $entry->status) { return true; } } return false; } private function verifyGRecaptcha() { $captchaSettings = $this->getCaptchaSettings(); $captchaV3Settings = $this->getCaptchaV3Settings(); if (!$captchaSettings && !$captchaV3Settings) { return; } if (!isset($_POST['g-recaptcha-response'])) { return new WP_Error('spam_detection', __('Please recheck your reCaptcha Configuration', 'bit-form')); } $token = sanitize_text_field(wp_unslash($_POST['g-recaptcha-response'])); $appType = $captchaSettings ? 'gReCaptcha' : 'gReCaptchaV3'; $reCAPTCHA = $this->getCaptchaApp($appType); // Fail closed: no secret key must not skip verification. if (empty($reCAPTCHA->secretKey)) { Log::debug_log("reCAPTCHA ({$appType}) is enabled on form {$this->form_id}, but no secret key is set in App Settings."); return new WP_Error('spam_detection', __('reCAPTCHA is not configured. Please contact the site administrator.', 'bit-form')); } $gRecaptchaResponse = HttpHelper::post( 'https://www.google.com/recaptcha/api/siteverify', ['secret' => $reCAPTCHA->secretKey, 'response' => $token] ); if (is_wp_error($gRecaptchaResponse)) { return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form')); } // Tokens are single-use; keep this verdict for the log. if ('gReCaptchaV3' === $appType) { $this->recaptchaV3Verification = ['integId' => $reCAPTCHA->id, 'response' => $gRecaptchaResponse]; } if ($captchaV3Settings && self::isRecaptchaScoreTooLow($gRecaptchaResponse, $captchaV3Settings)) { wp_send_json_error( sanitize_text_field((string) $captchaV3Settings->message) ); } if (!is_object($gRecaptchaResponse) || empty($gRecaptchaResponse->success)) { return new WP_Error('spam_detection', __('Please verify reCAPTCHA', 'bit-form')); } } /** * Connected captcha app from App Settings, with its integration id. * * @param string $appType gReCaptcha, gReCaptchaV3, hcaptcha or turnstileCaptcha * * @return object|null */ private function getCaptchaApp($appType) { $apps = (new IntegrationHandler(0))->getAllIntegration('app', $appType); if (is_wp_error($apps) || !is_array($apps)) { return null; } $reCAPTCHA = null; foreach ($apps as $integration) { if ($appType !== $integration->integration_type) { continue; } $details = Utilities::jsonObj($integration->integration_details); if (is_object($details)) { $details->id = $integration->id; $reCAPTCHA = $details; } } return $reCAPTCHA; } /** * Log the reCAPTCHA v3 verdict for this submission. * * @param array $saveResponse Entry save/update response * * @return void */ private function logRecaptchaV3Verification($saveResponse) { if (!$this->getCaptchaV3Settings()) { return; } unset($_POST['g-recaptcha-response']); if (empty($this->recaptchaV3Verification) || empty($saveResponse['triggerData']['logID'])) { return; } $response = $this->recaptchaV3Verification['response']; $status = is_object($response) && !empty($response->success) ? 'success' : 'error'; (new UtilApiResponse())->apiResponse( $saveResponse['triggerData']['logID'], $this->recaptchaV3Verification['integId'], ['type_name' => 'ReCaptcha', 'type' => 'v3'], $status, $response ); } /** * Whether the v3 score is below tolerance; 0.0 counts, blank tolerance means 0.6. * * @param mixed $response Decoded siteverify response * @param mixed $settings Form's recaptchav3 settings * * @return bool */ private static function isRecaptchaScoreTooLow($response, $settings) { if (!is_object($response) || !isset($response->score) || !is_numeric($response->score)) { return false; } $threshold = is_object($settings) && isset($settings->score) && is_numeric($settings->score) ? (float) $settings->score : 0.6; return (float) $response->score < $threshold; } private function verifyHCaptcha() { if (!$this->isFieldTypeExist('hcaptcha')) { return; } if (!isset($_POST['h-captcha-response'])) { return new WP_Error('spam_detection', __('Please verify hCaptcha', 'bit-form')); } $token = sanitize_text_field(wp_unslash($_POST['h-captcha-response'])); $hCaptcha = $this->getCaptchaApp('hcaptcha'); // Fail closed: no secret key must not skip verification. if (empty($hCaptcha->secretKey)) { Log::debug_log("hCaptcha is enabled on form {$this->form_id}, but no secret key is set in App Settings."); return new WP_Error('spam_detection', __('hCaptcha is not configured. Please contact the site administrator.', 'bit-form')); } $hCaptchaResponse = HttpHelper::post( 'https://api.hcaptcha.com/siteverify', [ 'secret' => $hCaptcha->secretKey, 'response' => $token, 'remoteip' => (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '') ] ); if (is_wp_error($hCaptchaResponse) || !is_object($hCaptchaResponse) || empty($hCaptchaResponse->success)) { return new WP_Error('spam_detection', __('hCaptcha verification failed', 'bit-form')); } } private function verifyTurnstileCaptcha() { if (!$this->isFieldTypeExist('turnstile')) { return; } if (!isset($_POST['cf-turnstile-response'])) { return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form')); } $token = sanitize_text_field(wp_unslash($_POST['cf-turnstile-response'])); $turnstileCaptcha = $this->getCaptchaApp('turnstileCaptcha'); // Fail closed: no secret key must not skip verification. if (empty($turnstileCaptcha->secretKey)) { Log::debug_log("Turnstile is enabled on form {$this->form_id}, but no secret key is set in App Settings."); return new WP_Error('spam_detection', __('Cloudflare Turnstile is not configured. Please contact the site administrator.', 'bit-form')); } $turnstileResponse = HttpHelper::post( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', ['secret' => $turnstileCaptcha->secretKey, 'response' => $token] ); if (is_wp_error($turnstileResponse) || !is_object($turnstileResponse)) { return new WP_Error('spam_detection', __('Please verify Cloudflare Turnstile Captcha', 'bit-form')); } if (empty($turnstileResponse->success)) { $errorCodes = implode(', ', (array) ($turnstileResponse->{'error-codes'} ?? [])); wp_send_json_error( sprintf( /* translators: %s: dynamic value. */ __('Cloudflare Turnstile Validation Error: %s', 'bit-form'), $errorCodes ) ); } } public function verifySubmissionNonce() { if (!isset($_POST['t_identity']) || !isset($_POST['csrf'])) { return false; } $tIdenty = sanitize_text_field(wp_unslash($_POST['t_identity'])); $csrf = sanitize_text_field(wp_unslash($_POST['csrf'])); unset($_POST['t_identity'], $_POST['action'], $_POST['bitforms_id'], $_POST['csrf']); return Helpers::csrfDecrypted($tIdenty, $csrf); } public function setViewCount() { if (!current_user_can('manage_options')) { $update_status = $this->formModel->update( [ 'views' => intval($this->form[0]->views) + 1 ], [ 'id' => $this->form_id ] ); } } /** * @param bool $checkedEmptySubmitted whether the empty-submission rule applies here * @param bool $isEntryEdit true when an existing entry is being updated */ public function checkSubmissionRestriction($checkedEmptySubmitted = true, $isEntryEdit = false) { $formContents = $this->getFormContent(); $additionalSettings = isset($formContents->additional) ? $formContents->additional : null; $fromRestrictionSetitingsEnabled = empty($additionalSettings->enabled) ? [] : $additionalSettings->enabled; $fromRestrictionSetitings = empty($additionalSettings->settings) ? null : $additionalSettings->settings; if (is_null($additionalSettings) || is_null($fromRestrictionSetitings) || empty((array) $fromRestrictionSetitingsEnabled)) { return false; } $restrictionMessage = []; $ipTool = new IpTool(); $ipAddress = $ipTool->getIP(); $currentUserId = get_current_user_id(); foreach ($fromRestrictionSetitingsEnabled as $restrictionKey => $isEnabled) { if ($isEnabled) { // Quota rules gate creating an entry, so an edit skips them; access-control keys stay. $skippableOnEdit = ['onePerIp', 'entry_limit', 'entry_limit_by_user', 'restrict_form']; if ($isEntryEdit && in_array($restrictionKey, $skippableOnEdit, true)) { $skipOnEdit = apply_filters( 'bitform_skip_restriction_on_entry_edit', true, $restrictionKey, $this->form_id ); if ($skipOnEdit) { continue; } } /** * Allow add-ons to handle any restriction key (Pro-only restrictions * should be implemented in the add-on, not shipped in the free plugin). * * Return a non-null string to block submission. */ $addonMsg = apply_filters( 'bitform_submission_restriction', null, $restrictionKey, $this->form_id, $fromRestrictionSetitingsEnabled, $fromRestrictionSetitings, $ipAddress, $currentUserId ); if (!is_null($addonMsg) && '' !== $addonMsg) { $restrictionMessage[] = $addonMsg; continue; } if ('onePerIp' === $restrictionKey) { if ($this->hasCompletedEntryFromIp($ipAddress)) { $onePerIp = __('Sorry!! You have already submitted from this IP address', 'bit-form'); $onePerIp = apply_filters( 'bitform_filter_restriction_one_per_ip_message', $onePerIp, $this->form_id ); $restrictionMessage[] = $onePerIp; } } if ('is_login' === $restrictionKey && 0 === get_current_user_id()) { $is_login_messages = $fromRestrictionSetitings->is_login->message; $is_login_messages = apply_filters( 'bitform_filter_restriction_is_login_message', $is_login_messages, $this->form_id ); $restrictionMessage[] = $is_login_messages; } if ($checkedEmptySubmitted && 'empty_submission' === $restrictionKey) { $isEmpty = $this->checkEmptySubmission(wp_unslash($_POST), GlobalHelper::sanitize_files_input($_FILES), $isEntryEdit); if ($isEmpty) { $restriction = $fromRestrictionSetitings->empty_submission->message; $restriction = apply_filters( 'bitform_filter_restriction_empty_submission_message', $restriction, $this->form_id ); $restrictionMessage[] = $restriction; } } } } return $restrictionMessage; } /** * Will check if form is submitted by a bot * * @return Boolean true - if submitted by bot else false */ public function isTrappedInHoneypot() { // Honeypot is implemented by add-ons (e.g. Pro) via filter. return (bool) apply_filters('bitform_check_honeypot', false, $this->_form_id, wp_unslash($_POST)); } public function isHoneypotActive() { return (bool) apply_filters('bitform_is_honeypot_active', false, $this->_form_id, $this->getFormContent()); } public function checkPaymentFields() { $formContents = $this->getFormContent(); $fields = $formContents->fields; $payments = []; foreach ($fields as $fldData) { if (!is_object($fldData)) { continue; } if ('paypal' === $fldData->typ && property_exists($fldData, 'payIntegID')) { $payments['paypalKey'] = $this->getClientKey($fldData->payIntegID, 'clientID'); } elseif ('razorpay' === $fldData->typ && isset($fldData->options) && is_object($fldData->options) && property_exists($fldData->options, 'payIntegID')) { $payments['razorpayKey'] = $this->getClientKey($fldData->options->payIntegID, 'apiKey'); } } return $payments; } private function getClientKey($integID, $keyName) { $client = ''; if (!empty($integID)) { $integrationHandler = new IntegrationHandler(0); $integration = $integrationHandler->getAIntegration($integID, 'app', 'payments'); if (!is_wp_error($integration)) { $integrationRow = Utilities::firstRow($integration); $integration_details = Utilities::jsonObj($integrationRow->integration_details ?? ''); if ($integration_details && isset($integration_details->{$keyName})) { $client = base64_encode($integration_details->{$keyName}); } } } return $client; } public function getSuccessMessageMarkups() { if (is_null($this->_conf_messages)) { $successMsgHandler = new SuccessMessageHandler($this->form_id); $this->_conf_messages = $successMsgHandler->getAllMessage(); } $messageMarkups = ''; if (is_wp_error($this->_conf_messages)) { return $messageMarkups; } foreach ($this->_conf_messages as $msgItem) { $msgConfig = json_decode($msgItem->message_config); if (is_object($msgConfig) && property_exists($msgConfig, 'status') && empty($msgConfig->status)) { continue; } $messageMarkups .= $this->messageMarkup($msgItem); } return $messageMarkups; } public function getFormAbandonmentMessage() { $msg = apply_filters('bitform_form_abandonment_warning_markup', '', $this->form_id); return is_string($msg) ? $msg : ''; } public function getFormAbandonmentSettings() { return apply_filters('bitform_form_abandonment_settings', null, $this->form_id); } private function messageMarkup($msg) { $msgId = $msg->id; $msgConfig = json_decode($msg->message_config); $msgType = (is_object($msgConfig) && isset($msgConfig->msgType)) ? $msgConfig->msgType : 'below'; $scrollClass = 'below' === $msgType ? 'scroll' : ''; return ''; } }