diff --git a/CHANGELOG.md b/CHANGELOG.md index 227c1bfe9..88405a38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. This projects adheres to [Semantic Versioning](https://semver.org/) and [Keep a CHANGELOG](https://keepachangelog.com/). +## [9.2.0] + +### Added + +- Added Friendly Captcha as an alternative spam prevention provider alongside Google reCAPTCHA. Includes site key/API key configuration, EU endpoint toggle, automatic SDK loading, and mutual exclusivity with reCAPTCHA. + ## [9.1.6] ### Changed @@ -1813,6 +1819,7 @@ This projects adheres to [Semantic Versioning](https://semver.org/) and [Keep a - Initial production release. +[9.2.0]: https://github.com/infinum/eightshift-forms/compare/9.1.6...9.2.0 [9.1.6]: https://github.com/infinum/eightshift-forms/compare/9.1.5...9.1.6 [9.1.5]: https://github.com/infinum/eightshift-forms/compare/9.1.4...9.1.5 [9.1.4]: https://github.com/infinum/eightshift-forms/compare/9.1.3...9.1.4 diff --git a/bun.lock b/bun.lock index 6c1df3cec..19a738acd 100644 --- a/bun.lock +++ b/bun.lock @@ -8,18 +8,18 @@ "@eightshift/frontend-libs": "^12.1.9", "@eightshift/ui-components": "^5.6.1", "autosize": "^6.0.1", - "baseline-browser-mapping": "^2.10.10", - "caniuse-lite": "^1.0.30001780", + "baseline-browser-mapping": "^2.10.18", + "caniuse-lite": "^1.0.30001787", "choices.js": "^11.2.1", "dropzone": "^6.0.0-beta.2", "flatpickr": "^4.6.13", "reactflow": "^11.11.4", }, "devDependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.59.1", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "webpack": "^5.105.4", + "webpack": "^5.106.1", "webpack-cli": "^6.0.1", }, }, diff --git a/eightshift-forms.php b/eightshift-forms.php index 539a13ba1..ff5397b81 100644 --- a/eightshift-forms.php +++ b/eightshift-forms.php @@ -6,7 +6,7 @@ * Description: Eightshift Forms is a complete form builder plugin that utilizes modern Block editor features with multiple third-party integrations, bringing your project to a new level. * Author: WordPress team @Infinum * Author URI: https://eightshift.com/ - * Version: 9.1.6 + * Version: 9.2.0 * Text Domain: eightshift-forms * * @package EightshiftForms diff --git a/package.json b/package.json index 9979f4cf7..d3eaf568d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eightshift/eightshift-forms", - "version": "9.1.6", + "version": "9.2.0", "description": "This repository contains all the tools you need to start building a modern WordPress project.", "authors": [ { diff --git a/src/Blocks/components/form/assets/form.js b/src/Blocks/components/form/assets/form.js index 3df726e85..5670d6a5b 100644 --- a/src/Blocks/components/form/assets/form.js +++ b/src/Blocks/components/form/assets/form.js @@ -573,18 +573,34 @@ export class Form { * * @returns {void} */ - runFormCaptcha(formId, filter = {}) { + async runFormCaptcha(formId, filter = {}) { if (!this.state.getStateCaptchaIsUsed()) { return; } - const actionName = this.state.getStateCaptchaSubmitAction(); - const siteKey = this.state.getStateCaptchaSiteKey(); + switch (this.state.getStateCaptchaType()) { + case StateEnum.CAPTCHA_TYPE_FRIENDLY: { + const widget = window[prefix]?.friendlyCaptcha; + const token = widget?.getResponse() ?? ''; - if (this.state.getStateCaptchaIsEnterprise()) { - this.executeEnterpriseCaptcha(actionName, siteKey, formId, false, filter); - } else { - this.executeFreeCaptcha(actionName, siteKey, formId, false, filter); + this.setFormDataCaptcha({ + token, + }); + + await this.formSubmit(formId, filter); + break; + } + default: { + const actionName = this.state.getStateCaptchaSubmitAction(); + const siteKey = this.state.getStateCaptchaSiteKey(); + + if (this.state.getStateCaptchaIsEnterprise()) { + this.executeEnterpriseCaptcha(actionName, siteKey, formId, false, filter); + } else { + this.executeFreeCaptcha(actionName, siteKey, formId, false, filter); + } + break; + } } } @@ -1978,7 +1994,7 @@ export class Form { custom?.showDropdown(); } - if (!this.state.getStateSettingsDisableScrollToFieldOnFocus()) { + if (!this.state.getStateSettingsDisableScrollToFieldOnFocus()) { this.utils.scrollAction(field); } diff --git a/src/Blocks/components/form/assets/friendly-captcha.js b/src/Blocks/components/form/assets/friendly-captcha.js new file mode 100644 index 000000000..674f8ad6d --- /dev/null +++ b/src/Blocks/components/form/assets/friendly-captcha.js @@ -0,0 +1,111 @@ +/* global frcaptcha */ + +import { prefix, setStateWindow } from './state-init'; + +/** + * FriendlyCaptcha class. + */ +export class FriendlyCaptcha { + constructor(utils) { + /** @type {import('./utils').Utils} */ + this.utils = utils; + /** @type {import('./state').State} */ + this.state = this.utils.getState(); + + this.widget = null; + + // Set all public methods. + this.publicMethods(); + } + + //////////////////////////////////////////////////////////////// + // Public methods + //////////////////////////////////////////////////////////////// + + /** + * Init all actions. + * + * @returns {void} + */ + init() { + if (!this.state.getStateCaptchaIsUsed()) { + return; + } + + if (!this.state.getStateCaptchaLoadOnInit() && !document.querySelectorAll(this.state.getStateSelector('form', true))?.length) { + return; + } + + this.initWidget(); + this.initResetOnSubmit(); + } + + /** + * Reset widget after each form submission so a fresh token is ready for the next attempt. + * + * @returns {void} + */ + initResetOnSubmit() { + window.addEventListener(this.state.getStateEvent('afterFormSubmit'), () => { + this.widget?.reset(); + }); + } + + /** + * Initialize Friendly Captcha widget. + * + * @returns {void} + */ + initWidget() { + if (typeof frcaptcha === 'undefined') { + return; + } + + const siteKey = this.state.getStateCaptchaSiteKey(); + + // Create a hidden container for the widget. + const container = document.createElement('div'); + container.style.display = 'none'; + document.body.appendChild(container); + + this.widget = frcaptcha.createWidget({ + element: container, + sitekey: siteKey, + startMode: 'auto', + apiEndpoint: this.state.getStateCaptchaEndpoint(), + }); + } + + //////////////////////////////////////////////////////////////// + // Private methods - not shared to the public window object. + //////////////////////////////////////////////////////////////// + + /** + * Set all public methods. + * + * @returns {void} + */ + publicMethods() { + setStateWindow(); + + if (window[prefix].friendlyCaptcha) { + return; + } + + window[prefix].friendlyCaptcha = { + init: () => { + this.init(); + }, + initWidget: () => { + this.initWidget(); + }, + initResetOnSubmit: () => { + this.initResetOnSubmit(); + }, + getResponse: () => this.widget?.getResponse() ?? '', + reset: () => { + this.widget?.reset(); + }, + }; + } +} diff --git a/src/Blocks/components/form/assets/index.js b/src/Blocks/components/form/assets/index.js index 5c638c844..b94f807ce 100644 --- a/src/Blocks/components/form/assets/index.js +++ b/src/Blocks/components/form/assets/index.js @@ -1,7 +1,7 @@ /* global esFormsLocalization */ import domReady from '@wordpress/dom-ready'; -import { setStateInitial } from './state-init'; +import { StateEnum, setStateInitial } from './state-init'; import { Utils } from './utils'; // Global variable must be set for everything to work. @@ -17,11 +17,19 @@ const utils = new Utils(); const state = utils.getState(); domReady(() => { - // Load captcha if using initial. if (state.getStateCaptchaIsUsed()) { - import('./captcha').then(({ Captcha }) => { - new Captcha(utils).init(); - }); + switch (state.getStateCaptchaType()) { + case StateEnum.CAPTCHA_TYPE_FRIENDLY: + import('./friendly-captcha').then(({ FriendlyCaptcha }) => { + new FriendlyCaptcha(utils).init(); + }); + break; + default: + import('./captcha').then(({ Captcha }) => { + new Captcha(utils).init(); + }); + break; + } } if (!state.getStateSettingsFormDisableAutoInit()) { diff --git a/src/Blocks/components/form/assets/state-init.js b/src/Blocks/components/form/assets/state-init.js index d9f2b000e..172e801a5 100644 --- a/src/Blocks/components/form/assets/state-init.js +++ b/src/Blocks/components/form/assets/state-init.js @@ -98,12 +98,17 @@ export const StateEnum = { SETTINGS_FORM_MISCONFIGURED_MSG: 'formMisconfigured', CAPTCHA: 'captcha', + CAPTCHA_TYPE: 'type', CAPTCHA_SITE_KEY: 'site_key', CAPTCHA_IS_ENTERPRISE: 'isEnterprise', CAPTCHA_SUBMIT_ACTION: 'submitAction', CAPTCHA_INIT_ACTION: 'initAction', CAPTCHA_LOAD_ON_INIT: 'loadOnInit', CAPTCHA_HIDE_BADGE: 'hideBadge', + CAPTCHA_ENDPOINT: 'endpoint', + + CAPTCHA_TYPE_GOOGLE: 'google', + CAPTCHA_TYPE_FRIENDLY: 'friendly', ENRICHMENT: 'enrichment', ENRICHMENT_FORM_PREFILL: 'formPrefill', @@ -255,17 +260,26 @@ export function setStateInitial() { setState([StateEnum.SETTINGS_FORM_CAPTCHA_ERROR_MSG], esFormsLocalization.formCaptchaErrorMsg ?? '', StateEnum.SETTINGS); setState([StateEnum.SETTINGS_FORM_MISCONFIGURED_MSG], esFormsLocalization.formMisconfigured ?? '', StateEnum.SETTINGS); - // Captcha. + // Captcha — single payload discriminated by `type`. const captcha = esFormsLocalization.captcha ?? {}; setState([StateEnum.IS_USED], Boolean(captcha.isUsed), StateEnum.CAPTCHA); if (captcha.isUsed) { + setState([StateEnum.CAPTCHA_TYPE], captcha.type, StateEnum.CAPTCHA); setState([StateEnum.CAPTCHA_SITE_KEY], captcha.siteKey, StateEnum.CAPTCHA); - setState([StateEnum.CAPTCHA_IS_ENTERPRISE], Boolean(captcha.isEnterprise), StateEnum.CAPTCHA); - setState([StateEnum.CAPTCHA_SUBMIT_ACTION], captcha.submitAction, StateEnum.CAPTCHA); - setState([StateEnum.CAPTCHA_INIT_ACTION], captcha.initAction, StateEnum.CAPTCHA); setState([StateEnum.CAPTCHA_LOAD_ON_INIT], Boolean(captcha.loadOnInit), StateEnum.CAPTCHA); - setState([StateEnum.CAPTCHA_HIDE_BADGE], Boolean(captcha.hideBadge), StateEnum.CAPTCHA); + + switch (captcha.type) { + case StateEnum.CAPTCHA_TYPE_FRIENDLY: + setState([StateEnum.CAPTCHA_ENDPOINT], captcha.endpoint, StateEnum.CAPTCHA); + break; + default: + setState([StateEnum.CAPTCHA_IS_ENTERPRISE], Boolean(captcha.isEnterprise), StateEnum.CAPTCHA); + setState([StateEnum.CAPTCHA_SUBMIT_ACTION], captcha.submitAction, StateEnum.CAPTCHA); + setState([StateEnum.CAPTCHA_INIT_ACTION], captcha.initAction, StateEnum.CAPTCHA); + setState([StateEnum.CAPTCHA_HIDE_BADGE], Boolean(captcha.hideBadge), StateEnum.CAPTCHA); + break; + } } // Geolocation. diff --git a/src/Blocks/components/form/assets/state.js b/src/Blocks/components/form/assets/state.js index 8d1e3dbd8..9a9177f60 100644 --- a/src/Blocks/components/form/assets/state.js +++ b/src/Blocks/components/form/assets/state.js @@ -430,6 +430,12 @@ export class State { getStateCaptchaHideBadge = () => { return getState([StateEnum.CAPTCHA_HIDE_BADGE], StateEnum.CAPTCHA); }; + getStateCaptchaType = () => { + return getState([StateEnum.CAPTCHA_TYPE], StateEnum.CAPTCHA); + }; + getStateCaptchaEndpoint = () => { + return getState([StateEnum.CAPTCHA_ENDPOINT], StateEnum.CAPTCHA); + }; //////////////////////////////////////////////////////////////// // Geolocation getters. diff --git a/src/Captcha/Captcha.php b/src/Captcha/Captcha.php index f8f397a56..bd9835e8e 100644 --- a/src/Captcha/Captcha.php +++ b/src/Captcha/Captcha.php @@ -1,7 +1,7 @@ labels = $labels; - $this->security = $security; + $this->recaptcha = $recaptcha; + $this->friendlyCaptcha = $friendlyCaptcha; } /** - * Check captcha request. + * Delegate to the active captcha provider. * * @param string $token Token from frontend. * @param string $action Action to check. * @param boolean $isEnterprise Type of captcha. * @param array $formDetails Form details. * - * @throws BadRequestException If captcha is not valid. - * * @return array */ public function check(string $token, string $action, bool $isEnterprise, array $formDetails = []): array { - if (!\apply_filters(SettingsCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { - return [ - AbstractBaseRoute::R_MSG => $this->labels->getLabel('captchaSuccess'), - AbstractBaseRoute::R_DEBUG => [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_FEATURE_DISABLED, - ], - ]; - } - - $isRetry = (bool) ($formDetails[Config::FD_CAPTCHA]['isRetry'] ?? false); - - $debug = [ - 'token' => $token, - 'action' => $action, - 'isEnterprise' => $isEnterprise, - 'isRetry' => $isRetry, - 'formDetails' => $formDetails, - ]; - - if (!$token) { - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('captchaBadRequest'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_REQUEST_MISSING_TOKEN, - AbstractBaseRoute::R_DEBUG => $debug, - ] - ); - // phpcs:enable - } - - if ($isEnterprise) { - $response = $this->onEnterprise($token, $action); - } else { - $response = $this->onFree($token); - } - - // Generic error msg from WP. - if (\is_wp_error($response)) { - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('submitWpError'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_REQUEST_WP_ERROR, - AbstractBaseRoute::R_DEBUG => $debug, - ] - ); - // phpcs:enable + switch (SettingsCaptcha::getActiveProvider()) { + case SettingsCaptcha::PROVIDER_FRIENDLY: + return $this->friendlyCaptcha->check($token, $action, $isEnterprise, $formDetails); + default: + return $this->recaptcha->check($token, $action, $isEnterprise, $formDetails); } - - // Get body from the response. - $responseBody = \json_decode(\wp_remote_retrieve_body($response), true) ?? []; - - if ($isEnterprise) { - return $this->getEnterpriseOutput($responseBody, $action, $debug, $isRetry); - } - - return $this->getFreeOutput($responseBody, $action, $debug, $isRetry); - } - - /** - * Get Enterprise response from api. - * - * @param string $token Token for captcha. - * @param string $action Action name. - * - * @return array|WP_Error - */ - private function onEnterprise(string $token, string $action): array|WP_Error - { - $siteKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), SettingsCaptcha::SETTINGS_CAPTCHA_SITE_KEY); - $apiKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaApiKey(), SettingsCaptcha::SETTINGS_CAPTCHA_API_KEY); - $projectIdKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaProjectIdKey(), SettingsCaptcha::SETTINGS_CAPTCHA_PROJECT_ID_KEY); - - $event = [ - 'siteKey' => $siteKey, - 'token' => $token, - 'expectedAction' => $action, - ]; - - // Google recommends sending userAgent and userIpAddress to improve detection. - $userAgent = $this->security->getUserAgent(); - if ($userAgent !== '') { - $event['userAgent'] = $userAgent; - } - - $userIp = $this->security->getIpAddress(); - if ($userIp !== '') { - $event['userIpAddress'] = $userIp; - } - - return \wp_remote_post( - "https://recaptchaenterprise.googleapis.com/v1/projects/{$projectIdKey}/assessments?key={$apiKey}", - [ - 'headers' => [ - 'Content-Type' => 'application/json; charset=utf-8', - 'Referer' => \site_url(), - ], - 'data_format' => 'body', - 'body' => \wp_json_encode([ - 'event' => $event, - ]), - ] - ); - } - - /** - * Get Enterprise response from api. - * - * @param string $token Token for captcha. - * - * @return array|WP_Error - */ - private function onFree(string $token): array|WP_Error - { - $secretKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSecretKey(), SettingsCaptcha::SETTINGS_CAPTCHA_SECRET_KEY); - - return \wp_remote_post( - "https://www.google.com/recaptcha/api/siteverify", - [ - 'body' => [ - 'secret' => $secretKey, - 'response' => $token, - ], - ] - ); - } - - /** - * Get enterprise output. - * - * @param array $responseBody Response body from API. - * @param string $action Action name. - * @param array $debug Debug data. - * @param bool $isRetry Whether this request is itself a client retry. - * - * @throws BadRequestException If captcha is not valid. - * - * @return mixed - */ - private function getEnterpriseOutput(array $responseBody, string $action, array $debug, bool $isRetry) - { - $debug = \array_merge($debug, [ - 'responseBody' => $responseBody, - 'action' => $action, - ]); - - if (!isset($responseBody['tokenProperties']['valid']) || !$responseBody['tokenProperties']['valid']) { - $errorCode = $responseBody['tokenProperties']['invalidReason'] ?? ''; - - $debug['invalidReason'] = $errorCode; - - $retry = \in_array($errorCode, self::ENTERPRISE_RETRY_REASONS, true); - - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('captchaError'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_ENTERPRISE_OUTPUT_ERROR, - AbstractBaseRoute::R_DEBUG => $debug, - ], - [ - UtilsHelper::getStateResponseOutputKey('captchaRetry') => $retry, - UtilsHelper::getStateResponseOutputKey('captchaSkipLogging') => $retry && !$isRetry, - ] - ); - // phpcs:enable - } - - // If response is error. - if (!isset($responseBody['riskAnalysis']['score'])) { - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('captchaError'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_ENTERPRISE_OUTPUT_ERROR, - AbstractBaseRoute::R_DEBUG => $debug, - ] - ); - // phpcs:enable - } - - return $this->validate( - $responseBody, - $action, - $responseBody['tokenProperties']['action'] ?? '', - $responseBody['riskAnalysis']['score'] ?? 0.0, - $debug - ); - } - - /** - * Get free output. - * - * @param array $responseBody Response body from API. - * @param string $action Action name. - * @param array $debug Debug data. - * @param bool $isRetry Whether this request is itself a client retry. - * - * @throws BadRequestException If captcha is not valid. - * - * @return mixed - */ - private function getFreeOutput(array $responseBody, string $action, array $debug, bool $isRetry) - { - $debug = \array_merge($debug, [ - 'responseBody' => $responseBody, - 'action' => $action, - ]); - - // If response is error. - if (!isset($responseBody['score'])) { - $errorCodes = $responseBody['error-codes'] ?? []; - - $debug['errorCodes'] = $errorCodes; - - $retry = (bool) \array_intersect(self::FREE_RETRY_REASONS, $errorCodes); - - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('captchaError'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_FREE_OUTPUT_ERROR, - AbstractBaseRoute::R_DEBUG => $debug, - ], - [ - UtilsHelper::getStateResponseOutputKey('captchaRetry') => $retry, - UtilsHelper::getStateResponseOutputKey('captchaSkipLogging') => $retry && !$isRetry, - ] - ); - // phpcs:enable - } - - return $this->validate( - $responseBody, - $action, - $responseBody['action'] ?? '', - $responseBody['score'] ?? 0.0, - $debug - ); - } - - /** - * Validate and return if issue. - * - * @param mixed $responseBody Response body from API. - * @param string $action Action name. - * @param string $actionResponse Action response from API. - * @param float $score Score value Score value from API. - * @param array $debug Debug data. - * - * @throws BadRequestException If captcha is not valid. - * - * @return mixed - */ - private function validate( - $responseBody, - string $action, - string $actionResponse, - float $score, - array $debug - ) { - $debug = \array_merge($debug, [ - 'responseBody' => $responseBody, - 'action' => $action, - 'actionResponse' => $actionResponse, - 'score' => $score, - ]); - - // Bailout if action is not correct. - if ($actionResponse !== $action) { - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('captchaWrongAction'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_WRONG_ACTION, - AbstractBaseRoute::R_DEBUG => $debug, - ] - ); - // phpcs:enable - } - - $setScore = SettingsHelpers::getOptionValue(SettingsCaptcha::SETTINGS_CAPTCHA_SCORE_KEY) ?: SettingsCaptcha::SETTINGS_CAPTCHA_SCORE_DEFAULT_KEY; // phpcs:ignore WordPress.PHP.DisallowShortTernary.Found - - // Bailout on spam. - if (\floatval($score) < \floatval($setScore)) { - // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped - throw new BadRequestException( - $this->labels->getLabel('captchaScoreSpam'), - [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_SCORE_SPAM, - AbstractBaseRoute::R_DEBUG => $debug, - ], - [ - UtilsHelper::getStateResponseOutputKey('captchaIsSpam') => true, - ] - ); - // phpcs:enable - } - - return [ - AbstractBaseRoute::R_MSG => $this->labels->getLabel('captchaSuccess'), - AbstractBaseRoute::R_DEBUG => [ - AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_SUCCESS, - AbstractBaseRoute::R_DEBUG => $debug, - ], - AbstractBaseRoute::R_DATA => [ - UtilsHelper::getStateResponseOutputKey('captchaIsSpam') => false, - ], - ]; } } diff --git a/src/Captcha/FriendlyCaptcha.php b/src/Captcha/FriendlyCaptcha.php new file mode 100644 index 000000000..0bf84a733 --- /dev/null +++ b/src/Captcha/FriendlyCaptcha.php @@ -0,0 +1,252 @@ +labels = $labels; + } + + /** + * Check captcha request. + * + * Friendly Captcha does not use `$action` or `$isEnterprise` — they are + * accepted only for interface compatibility with Google reCAPTCHA. + * + * @param string $token Token from frontend. + * @param string $action Action to check. + * @param boolean $isEnterprise Type of captcha. + * @param array $formDetails Form details. + * + * @throws BadRequestException If captcha is not valid. + * + * @return array + */ + public function check(string $token, string $action, bool $isEnterprise, array $formDetails = []): array + { + if (!\apply_filters(SettingsFriendlyCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { + return [ + AbstractBaseRoute::R_MSG => $this->labels->getLabel('friendlyCaptchaSuccess'), + AbstractBaseRoute::R_DEBUG => [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_FEATURE_DISABLED, + ], + ]; + } + + $debug = [ + 'token' => $token, + 'formDetails' => $formDetails, + ]; + + if (!$token) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaBadRequest'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_REQUEST_MISSING_TOKEN, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + $siteKey = SettingsHelpers::getOptionWithConstant(Variables::getFriendlyCaptchaSiteKey(), SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_SITE_KEY); + $apiKey = SettingsHelpers::getOptionWithConstant(Variables::getFriendlyCaptchaApiKey(), SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_API_KEY); + + $response = \wp_remote_post( + self::getEndpointUrl(), + [ + 'headers' => [ + 'Content-Type' => 'application/json; charset=utf-8', + 'X-API-Key' => $apiKey, + ], + 'data_format' => 'body', + 'body' => \wp_json_encode([ + 'response' => $token, + 'sitekey' => $siteKey, + ]), + ] + ); + + // Generic error msg from WP. + if (\is_wp_error($response)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('submitWpError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_REQUEST_WP_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + $responseCode = (int) \wp_remote_retrieve_response_code($response); + $responseBody = \json_decode(\wp_remote_retrieve_body($response), true) ?? []; + $errorCode = (string) ($responseBody['errors'][0]['error_code'] ?? ''); + + $debug['responseCode'] = $responseCode; + $debug['responseBody'] = $responseBody; + $debug['errorCode'] = $errorCode; + + if (ApiHelpers::isSuccessResponse($responseCode)) { + return [ + AbstractBaseRoute::R_MSG => $this->labels->getLabel('friendlyCaptchaSuccess'), + AbstractBaseRoute::R_DEBUG => [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_SUCCESS, + AbstractBaseRoute::R_DEBUG => $debug, + ], + ]; + } + + // Auth issues — bad/missing API key. + if (\in_array($errorCode, self::ERROR_CODES_AUTH, true)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaAuthError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_AUTH_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + // Malformed request rejected by Friendly Captcha (sitekey/payload issue). + if (\in_array($errorCode, self::ERROR_CODES_BAD_REQUEST, true)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaBadRequest'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_BAD_REQUEST, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + // Solution expired or replayed — recoverable by requesting a fresh widget solution. + if (\in_array($errorCode, self::ERROR_CODES_TIMEOUT_OR_DUPLICATE, true)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaTimeoutOrDuplicate'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_TIMEOUT_OR_DUPLICATE, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + // Solution failed validation — likely a bot or tampered token. + if ($errorCode === 'solution_invalid') { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaInvalidSolution'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_INVALID_SOLUTION, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + // Non-success HTTP status with no recognised error code (e.g. 5xx). + if (ApiHelpers::isErrorResponse($responseCode)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaHttpError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_HTTP_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + // Generic catch-all for any other unsuccessful response. + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('friendlyCaptchaError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_OUTPUT_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + /** + * Get the selected endpoint value. + * + * @return string + */ + public static function getEndpoint(): string + { + return SettingsHelpers::isOptionCheckboxChecked(SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY, SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY) ? 'eu' : 'global'; + } + + /** + * Get the siteverify URL for the selected endpoint. + * + * @return string + */ + public static function getEndpointUrl(): string + { + return self::getEndpoint() === 'eu' ? self::FRIENDLY_CAPTCHA_ENDPOINT_EU_URL : self::FRIENDLY_CAPTCHA_ENDPOINT_GLOBAL_URL; + } +} diff --git a/src/Captcha/Recaptcha.php b/src/Captcha/Recaptcha.php new file mode 100644 index 000000000..8005ed2b5 --- /dev/null +++ b/src/Captcha/Recaptcha.php @@ -0,0 +1,390 @@ +labels = $labels; + $this->security = $security; + } + + /** + * Check captcha request. + * + * @param string $token Token from frontend. + * @param string $action Action to check. + * @param boolean $isEnterprise Type of captcha. + * @param array $formDetails Form details. + * + * @throws BadRequestException If captcha is not valid. + * + * @return array + */ + public function check(string $token, string $action, bool $isEnterprise, array $formDetails = []): array + { + if (!\apply_filters(SettingsRecaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { + return [ + AbstractBaseRoute::R_MSG => $this->labels->getLabel('captchaSuccess'), + AbstractBaseRoute::R_DEBUG => [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_FEATURE_DISABLED, + ], + ]; + } + + $isRetry = (bool) ($formDetails[Config::FD_CAPTCHA]['isRetry'] ?? false); + + $debug = [ + 'token' => $token, + 'action' => $action, + 'isEnterprise' => $isEnterprise, + 'isRetry' => $isRetry, + 'formDetails' => $formDetails, + ]; + + if (!$token) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('captchaBadRequest'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_REQUEST_MISSING_TOKEN, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + if ($isEnterprise) { + $response = $this->onEnterprise($token, $action); + } else { + $response = $this->onFree($token); + } + + // Generic error msg from WP. + if (\is_wp_error($response)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('submitWpError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_REQUEST_WP_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + // Get body from the response. + $responseBody = \json_decode(\wp_remote_retrieve_body($response), true) ?? []; + + if ($isEnterprise) { + return $this->getEnterpriseOutput($responseBody, $action, $debug, $isRetry); + } + + return $this->getFreeOutput($responseBody, $action, $debug, $isRetry); + } + + /** + * Get Enterprise response from api. + * + * @param string $token Token for captcha. + * @param string $action Action name. + * + * @return array|WP_Error + */ + private function onEnterprise(string $token, string $action): array|WP_Error + { + $siteKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), SettingsRecaptcha::SETTINGS_CAPTCHA_SITE_KEY); + $apiKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaApiKey(), SettingsRecaptcha::SETTINGS_CAPTCHA_API_KEY); + $projectIdKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaProjectIdKey(), SettingsRecaptcha::SETTINGS_CAPTCHA_PROJECT_ID_KEY); + + $event = [ + 'siteKey' => $siteKey, + 'token' => $token, + 'expectedAction' => $action, + ]; + + // Google recommends sending userAgent and userIpAddress to improve detection. + $userAgent = $this->security->getUserAgent(); + if ($userAgent !== '') { + $event['userAgent'] = $userAgent; + } + + $userIp = $this->security->getIpAddress(); + if ($userIp !== '') { + $event['userIpAddress'] = $userIp; + } + + return \wp_remote_post( + "https://recaptchaenterprise.googleapis.com/v1/projects/{$projectIdKey}/assessments?key={$apiKey}", + [ + 'headers' => [ + 'Content-Type' => 'application/json; charset=utf-8', + 'Referer' => \site_url(), + ], + 'data_format' => 'body', + 'body' => \wp_json_encode([ + 'event' => $event, + ]), + ] + ); + } + + /** + * Get Enterprise response from api. + * + * @param string $token Token for captcha. + * + * @return array|WP_Error + */ + private function onFree(string $token): array|WP_Error + { + $secretKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSecretKey(), SettingsRecaptcha::SETTINGS_CAPTCHA_SECRET_KEY); + + return \wp_remote_post( + "https://www.google.com/recaptcha/api/siteverify", + [ + 'body' => [ + 'secret' => $secretKey, + 'response' => $token, + ], + ] + ); + } + + /** + * Get enterprise output. + * + * @param array $responseBody Response body from API. + * @param string $action Action name. + * @param array $debug Debug data. + * @param bool $isRetry Whether this request is itself a client retry. + * + * @throws BadRequestException If captcha is not valid. + * + * @return mixed + */ + private function getEnterpriseOutput(array $responseBody, string $action, array $debug, bool $isRetry) + { + $debug = \array_merge($debug, [ + 'responseBody' => $responseBody, + 'action' => $action, + ]); + + if (!isset($responseBody['tokenProperties']['valid']) || !$responseBody['tokenProperties']['valid']) { + $errorCode = $responseBody['tokenProperties']['invalidReason'] ?? ''; + + $debug['invalidReason'] = $errorCode; + + $retry = \in_array($errorCode, self::ENTERPRISE_RETRY_REASONS, true); + + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('captchaError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_ENTERPRISE_OUTPUT_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ], + [ + UtilsHelper::getStateResponseOutputKey('captchaRetry') => $retry, + UtilsHelper::getStateResponseOutputKey('captchaSkipLogging') => $retry && !$isRetry, + ] + ); + // phpcs:enable + } + + // If response is error. + if (!isset($responseBody['riskAnalysis']['score'])) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('captchaError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_ENTERPRISE_OUTPUT_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + return $this->validate( + $responseBody, + $action, + $responseBody['tokenProperties']['action'] ?? '', + $responseBody['riskAnalysis']['score'] ?? 0.0, + $debug + ); + } + + /** + * Get free output. + * + * @param array $responseBody Response body from API. + * @param string $action Action name. + * @param array $debug Debug data. + * @param bool $isRetry Whether this request is itself a client retry. + * + * @throws BadRequestException If captcha is not valid. + * + * @return mixed + */ + private function getFreeOutput(array $responseBody, string $action, array $debug, bool $isRetry) + { + $debug = \array_merge($debug, [ + 'responseBody' => $responseBody, + 'action' => $action, + ]); + + // If response is error. + if (!isset($responseBody['score'])) { + $errorCodes = $responseBody['error-codes'] ?? []; + + $debug['errorCodes'] = $errorCodes; + + $retry = (bool) \array_intersect(self::FREE_RETRY_REASONS, $errorCodes); + + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('captchaError'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_FREE_OUTPUT_ERROR, + AbstractBaseRoute::R_DEBUG => $debug, + ], + [ + UtilsHelper::getStateResponseOutputKey('captchaRetry') => $retry, + UtilsHelper::getStateResponseOutputKey('captchaSkipLogging') => $retry && !$isRetry, + ] + ); + // phpcs:enable + } + + return $this->validate( + $responseBody, + $action, + $responseBody['action'] ?? '', + $responseBody['score'] ?? 0.0, + $debug + ); + } + + /** + * Validate and return if issue. + * + * @param mixed $responseBody Response body from API. + * @param string $action Action name. + * @param string $actionResponse Action response from API. + * @param float $score Score value Score value from API. + * @param array $debug Debug data. + * + * @throws BadRequestException If captcha is not valid. + * + * @return mixed + */ + private function validate( + $responseBody, + string $action, + string $actionResponse, + float $score, + array $debug + ) { + $debug = \array_merge($debug, [ + 'responseBody' => $responseBody, + 'action' => $action, + 'actionResponse' => $actionResponse, + 'score' => $score, + ]); + + // Bailout if action is not correct. + if ($actionResponse !== $action) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('captchaWrongAction'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_WRONG_ACTION, + AbstractBaseRoute::R_DEBUG => $debug, + ] + ); + // phpcs:enable + } + + $setScore = SettingsHelpers::getOptionValue(SettingsRecaptcha::SETTINGS_CAPTCHA_SCORE_KEY) ?: SettingsRecaptcha::SETTINGS_CAPTCHA_SCORE_DEFAULT_KEY; // phpcs:ignore WordPress.PHP.DisallowShortTernary.Found + + // Bailout on spam. + if (\floatval($score) < \floatval($setScore)) { + // phpcs:disable Eightshift.Security.HelpersEscape.ExceptionNotEscaped + throw new BadRequestException( + $this->labels->getLabel('captchaScoreSpam'), + [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_SCORE_SPAM, + AbstractBaseRoute::R_DEBUG => $debug, + ], + [ + UtilsHelper::getStateResponseOutputKey('captchaIsSpam') => true, + ] + ); + // phpcs:enable + } + + return [ + AbstractBaseRoute::R_MSG => $this->labels->getLabel('captchaSuccess'), + AbstractBaseRoute::R_DEBUG => [ + AbstractBaseRoute::R_DEBUG_KEY => SettingsFallback::SETTINGS_FALLBACK_FLAG_CAPTCHA_SUCCESS, + AbstractBaseRoute::R_DEBUG => $debug, + ], + AbstractBaseRoute::R_DATA => [ + UtilsHelper::getStateResponseOutputKey('captchaIsSpam') => false, + ], + ]; + } +} diff --git a/src/Captcha/SettingsCaptcha.php b/src/Captcha/SettingsCaptcha.php index 29f2d57d6..ce3534ef4 100644 --- a/src/Captcha/SettingsCaptcha.php +++ b/src/Captcha/SettingsCaptcha.php @@ -1,7 +1,7 @@ labels = $labels; + \add_filter(self::FILTER_SETTINGS_GLOBAL_NAME, [$this, 'getSettingsGlobalData']); + \add_filter(self::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, [$this, 'isSettingsGlobalValid']); } /** - * Register all the hooks + * Resolve the provider currently selected by the admin. * - * @return void + * Falls back to Google so existing installs (which only have `captcha-use` + * set) keep working untouched after upgrade. + * + * @return string Provider identifier — either `google` or `friendly`. */ - public function register(): void + public static function getActiveProvider(): string { - \add_filter(self::FILTER_SETTINGS_GLOBAL_NAME, [$this, 'getSettingsGlobalData']); - \add_filter(self::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, [$this, 'isSettingsGlobalValid']); + $value = SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_PROVIDER_KEY); + + return $value === self::PROVIDER_FRIENDLY ? self::PROVIDER_FRIENDLY : self::PROVIDER_GOOGLE; } /** - * Determine if settings global are valid. + * The merged page is valid whenever the active provider's own validity filter says so. * - * @return boolean + * @return bool */ public function isSettingsGlobalValid(): bool { - $isUsed = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_USE_KEY, self::SETTINGS_CAPTCHA_USE_KEY); - $siteKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), self::SETTINGS_CAPTCHA_SITE_KEY); - $secretKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSecretKey(), self::SETTINGS_CAPTCHA_SECRET_KEY); - $apiKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaApiKey(), self::SETTINGS_CAPTCHA_API_KEY); - $projectIdKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaProjectIdKey(), self::SETTINGS_CAPTCHA_PROJECT_ID_KEY); - - $isEnterprise = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_ENTERPRISE_KEY, self::SETTINGS_CAPTCHA_ENTERPRISE_KEY); - - if ($isEnterprise) { - if (!$isUsed || !$siteKey || !$apiKey || !$projectIdKey) { - return false; - } - } else { - if (!$isUsed || !$siteKey || !$secretKey) { - return false; - } - } + $providerFilter = self::getActiveProvider() === self::PROVIDER_FRIENDLY + ? SettingsFriendlyCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME + : SettingsRecaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME; - return true; + return (bool) \apply_filters($providerFilter, false); } /** - * Get global settings array for building settings page. + * Build the merged settings page. + * + * Lays the page out Corvus-style: a flat tab bar with the provider select + * as the driver field at the top of the first tab, and downstream tabs + * conditionally present based on the active provider. * * @return array> */ public function getSettingsGlobalData(): array { - // Bailout if feature is not active. if (!SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_USE_KEY, self::SETTINGS_CAPTCHA_USE_KEY)) { return SettingsOutputHelpers::getNoActiveFeature(); } - $isEnterprise = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_ENTERPRISE_KEY, self::SETTINGS_CAPTCHA_ENTERPRISE_KEY); - $isInit = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY); + $provider = self::getActiveProvider(); + + $providerSelect = [ + 'component' => 'select', + 'selectName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_PROVIDER_KEY), + 'selectFieldLabel' => \__('Provider', 'eightshift-forms'), + // phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings + 'selectFieldHelp' => \__('Pick which captcha service validates submissions. Switching the provider reloads the fields below.', 'eightshift-forms'), + 'selectSingleSubmit' => true, + 'selectContent' => [ + [ + 'component' => 'select-option', + 'selectOptionLabel' => \__('Google reCAPTCHA', 'eightshift-forms'), + 'selectOptionValue' => self::PROVIDER_GOOGLE, + 'selectOptionIsSelected' => $provider === self::PROVIDER_GOOGLE, + ], + [ + 'component' => 'select-option', + 'selectOptionLabel' => \__('Friendly Captcha', 'eightshift-forms'), + 'selectOptionValue' => self::PROVIDER_FRIENDLY, + 'selectOptionIsSelected' => $provider === self::PROVIDER_FRIENDLY, + ], + ], + ]; + + $divider = [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ]; + + $providerGeneralContent = $provider === self::PROVIDER_FRIENDLY + ? SettingsFriendlyCaptcha::getGeneralContent() + : SettingsRecaptcha::getGeneralContent(); + + $providerHelpContent = $provider === self::PROVIDER_FRIENDLY + ? SettingsFriendlyCaptcha::getHelpContent() + : SettingsRecaptcha::getHelpContent(); return [ - SettingsOutputHelpers::getIntro(self::SETTINGS_TYPE_KEY), - [ - 'component' => 'intro', - // phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings - 'introSubtitle' => \__('Protect your website from spam and abuse using Google\'s reCAPTCHA.
A captcha is a simple task that is easy for humans to do, but difficult for bots.', 'eightshift-forms'), - ], + SettingsOutputHelpers::getIntro('captcha'), [ 'component' => 'tabs', 'tabsContent' => [ [ 'component' => 'tab', - 'tabLabel' => \__('General', 'eightshift-forms'), + 'tabLabel' => \__('Settings', 'eightshift-forms'), 'tabContent' => [ - [ - 'component' => 'checkboxes', - 'checkboxesFieldHideLabel' => true, - 'checkboxesName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_ENTERPRISE_KEY), - 'checkboxesContent' => [ - [ - 'component' => 'checkbox', - 'checkboxLabel' => \__('Use reCAPTCHA Enterprise', 'eightshift-forms'), - 'checkboxIsChecked' => $isEnterprise, - 'checkboxValue' => self::SETTINGS_CAPTCHA_ENTERPRISE_KEY, - 'checkboxSingleSubmit' => true, - 'checkboxAsToggle' => true, - ], - ], - ], - [ - 'component' => 'divider', - 'dividerExtraVSpacing' => true, - ], - SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( - Variables::getGoogleReCaptchaSiteKey(), - self::SETTINGS_CAPTCHA_SITE_KEY, - 'ES_GOOGLE_RECAPTCHA_SITE_KEY', - \__('Site key', 'eightshift-forms'), - ), - - ...(!$isEnterprise ? [ - SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( - Variables::getGoogleReCaptchaSecretKey(), - self::SETTINGS_CAPTCHA_SECRET_KEY, - 'ES_GOOGLE_RECAPTCHA_SECRET_KEY', - \__('Secret key', 'eightshift-forms'), - ), - ] : [ - SettingsOutputHelpers::getInputFieldWithGlobalVariable( - Variables::getGoogleReCaptchaProjectIdKey(), - self::SETTINGS_CAPTCHA_PROJECT_ID_KEY, - 'ES_GOOGLE_RECAPTCHA_PROJECT_ID_KEY', - \__('Project ID', 'eightshift-forms'), - ), - SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( - Variables::getGoogleReCaptchaApiKey(), - self::SETTINGS_CAPTCHA_API_KEY, - 'ES_GOOGLE_RECAPTCHA_API_KEY', - \__('API key', 'eightshift-forms'), - ), - ]), + $providerSelect, + $divider, + ...$providerGeneralContent, ], ], - [ - 'component' => 'tab', - 'tabLabel' => \__('Advanced', 'eightshift-forms'), - 'tabContent' => [ - [ - 'component' => 'checkboxes', - 'checkboxesFieldHideLabel' => true, - 'checkboxesName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY), - 'checkboxesContent' => [ - [ - 'component' => 'checkbox', - 'checkboxLabel' => \__('Hide badge', 'eightshift-forms'), - 'checkboxIsChecked' => SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY, self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY), - 'checkboxValue' => self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY, - 'checkboxSingleSubmit' => true, - 'checkboxAsToggle' => true, - 'checkboxHelp' => \__('Not recommended, as it is against Google\'s terms of use.', 'eightshift-forms'), - ], - ], - ], - [ - 'component' => 'divider', - 'dividerExtraVSpacing' => true, - ], - [ - 'component' => 'input', - 'inputName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_SCORE_KEY), - 'inputFieldLabel' => \__('"Spam unlikely" threshold', 'eightshift-forms'), - 'inputFieldHelp' => \__('The level above which a submission is not considered spam. Should be between 0.1 and 1.0.
In most cases, a user will receive as core between 0.8 and 0.9.', 'eightshift-forms'), - 'inputType' => 'number', - 'inputValue' => SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_SCORE_KEY), - 'inputMin' => 0.1, - 'inputMax' => 1, - 'inputStep' => 0.1, - 'inputIsNumber' => true, - 'inputPlaceholder' => self::SETTINGS_CAPTCHA_SCORE_DEFAULT_KEY, - ], - [ - 'component' => 'divider', - 'dividerExtraVSpacing' => true, - ], - [ - 'component' => 'input', - 'inputName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_SUBMIT_ACTION_KEY), - 'inputFieldLabel' => \__('"On submit" action name', 'eightshift-forms'), - 'inputFieldHelp' => \__('Name of the action sent to reCAPTCHA on form submission.', 'eightshift-forms'), - 'inputType' => 'text', - 'inputValue' => SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_SUBMIT_ACTION_KEY), - 'inputPlaceholder' => self::SETTINGS_CAPTCHA_SUBMIT_ACTION_DEFAULT_KEY, - ], - [ - 'component' => 'divider', - 'dividerExtraVSpacing' => true, - ], - [ - 'component' => 'checkboxes', - 'checkboxesFieldHideLabel' => true, - 'checkboxesName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY), - 'checkboxesContent' => [ - [ - 'component' => 'checkbox', - 'checkboxLabel' => \__('Load Captcha on website load', 'eightshift-forms'), - 'checkboxIsChecked' => SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY), - 'checkboxValue' => self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, - 'checkboxHelp' => \__('By default, Captcha is only loaded on pages that contain forms. However, with this option, you can load Captcha on every page.', 'eightshift-forms'), - 'checkboxSingleSubmit' => true, - 'checkboxAsToggle' => true, - 'checkboxAsToggleSize' => 'medium', - ], - ], - ], - $isInit ? [ - 'component' => 'input', - 'inputName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_INIT_ACTION_KEY), - 'inputFieldLabel' => \__('Action name', 'eightshift-forms'), - 'inputFieldHelp' => \__('Name of the action sent to reCAPTCHA when Captcha is loaded on every page.', 'eightshift-forms'), - 'inputType' => 'text', - 'inputValue' => SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_INIT_ACTION_KEY), - 'inputPlaceholder' => self::SETTINGS_CAPTCHA_INIT_ACTION_DEFAULT_KEY, - ] : [], + ...($provider === self::PROVIDER_GOOGLE ? [ + [ + 'component' => 'tab', + 'tabLabel' => \__('Advanced', 'eightshift-forms'), + 'tabContent' => SettingsRecaptcha::getAdvancedContent(), ], - ], + ] : []), [ 'component' => 'tab', 'tabLabel' => \__('Help', 'eightshift-forms'), - 'tabContent' => [ - [ - 'component' => 'steps', - 'stepsTitle' => \__('How to get the Free reCAPTCHA API key?', 'eightshift-forms'), - 'stepsContent' => [ - // translators: %s will be replaced with the external link. - \sprintf(\__('Visit this link.', 'eightshift-forms'), 'https://www.google.com/recaptcha/admin/create'), - \__('Configure all the options. Make sure to select reCaptcha version 3!', 'eightshift-forms'), - \__('Copy the API key into the field under the API tab or use the global constant.', 'eightshift-forms'), - ], - ], - [ - 'component' => 'divider', - 'dividerExtraVSpacing' => true, - ], - [ - 'component' => 'steps', - 'stepsTitle' => \__('How to get the Enterprise reCAPTCHA API key?', 'eightshift-forms'), - 'stepsContent' => [ - // translators: %s will be replaced with the external link. - \sprintf(\__('Visit Google Cloud Console.', 'eightshift-forms'), 'https://console.cloud.google.com/'), - \__('Create a new project and set that project as Project ID.', 'eightshift-forms'), - \__('Search and go to reCAPTCHA product.', 'eightshift-forms'), - \__('You will probably need to set billing service for this product.', 'eightshift-forms'), - \__('Create a new key and set that key as Site key.', 'eightshift-forms'), - // translators: %s will be replaced with the website domain. - \sprintf(\__('Limit the key to your website domain. Domain: %s (exact, no trailing slash and protocol).', 'eightshift-forms'), \preg_replace("(^https?://)", "", \site_url())), - \__('Search and go to API & Services product.', 'eightshift-forms'), - \__('Go to Credentials section and create a new API key.', 'eightshift-forms'), - // translators: %s will be replaced with the website domain. - \sprintf(\__('Create a new key for Website, add restrictions to your website domain %s (exact, no trailing slash, with protocol) and set API restrictions to reCAPTCHA Enterprise.', 'eightshift-forms'), \esc_url(\site_url())), - \__('Set that key as API key.', 'eightshift-forms'), - ], - ], - ], + 'tabContent' => $providerHelpContent, ], ], ], diff --git a/src/Captcha/SettingsFriendlyCaptcha.php b/src/Captcha/SettingsFriendlyCaptcha.php new file mode 100644 index 000000000..c5fa4122b --- /dev/null +++ b/src/Captcha/SettingsFriendlyCaptcha.php @@ -0,0 +1,198 @@ +labels = $labels; + } + + /** + * Register all the hooks. + * + * @return void + */ + public function register(): void + { + \add_filter(self::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, [$this, 'isSettingsGlobalValid']); + } + + /** + * Determine if settings global are valid. + * + * @return boolean + */ + public function isSettingsGlobalValid(): bool + { + if (SettingsCaptcha::getActiveProvider() !== SettingsCaptcha::PROVIDER_FRIENDLY) { + return false; + } + + if (!SettingsHelpers::isOptionCheckboxChecked(SettingsCaptcha::SETTINGS_CAPTCHA_USE_KEY, SettingsCaptcha::SETTINGS_CAPTCHA_USE_KEY)) { + return false; + } + + $siteKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getFriendlyCaptchaSiteKey(), self::SETTINGS_FRIENDLY_CAPTCHA_SITE_KEY); + $apiKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getFriendlyCaptchaApiKey(), self::SETTINGS_FRIENDLY_CAPTCHA_API_KEY); + + return $siteKey && $apiKey; + } + + /** + * Field list for the "Settings" tab — API keys and EU endpoint toggle. + * + * @return array> + */ + public static function getGeneralContent(): array + { + return [ + [ + 'component' => 'intro', + // phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings + 'introSubtitle' => \__('Protect your forms from spam and abuse using Friendly Captcha.
A privacy-focused, GDPR-compliant alternative to Google reCAPTCHA.', 'eightshift-forms'), + ], + SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( + Variables::getFriendlyCaptchaSiteKey(), + self::SETTINGS_FRIENDLY_CAPTCHA_SITE_KEY, + 'ES_FRIENDLY_CAPTCHA_SITE_KEY', + \__('Site key', 'eightshift-forms'), + ), + SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( + Variables::getFriendlyCaptchaApiKey(), + self::SETTINGS_FRIENDLY_CAPTCHA_API_KEY, + 'ES_FRIENDLY_CAPTCHA_API_KEY', + \__('API key', 'eightshift-forms'), + ), + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + [ + 'component' => 'checkboxes', + 'checkboxesFieldLabel' => '', + 'checkboxesName' => SettingsHelpers::getSettingName(self::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY), + // phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings + 'checkboxesFieldHelp' => \__('The EU endpoint is hosted in Germany and ensures visitor data never leaves the EU.
Requires a Friendly Captcha Advanced or Enterprise plan.', 'eightshift-forms'), + 'checkboxesContent' => [ + [ + 'component' => 'checkbox', + 'checkboxLabel' => \__('Use EU endpoint', 'eightshift-forms'), + 'checkboxIsChecked' => SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY, self::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY), + 'checkboxValue' => self::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY, + 'checkboxAsToggle' => true, + ], + ], + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + [ + 'component' => 'checkboxes', + 'checkboxesFieldHideLabel' => true, + 'checkboxesName' => SettingsHelpers::getSettingName(self::SETTINGS_FRIENDLY_CAPTCHA_LOAD_ON_INIT_KEY), + 'checkboxesContent' => [ + [ + 'component' => 'checkbox', + 'checkboxLabel' => \__('Load widget on website load', 'eightshift-forms'), + 'checkboxIsChecked' => SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_FRIENDLY_CAPTCHA_LOAD_ON_INIT_KEY, self::SETTINGS_FRIENDLY_CAPTCHA_LOAD_ON_INIT_KEY), + 'checkboxValue' => self::SETTINGS_FRIENDLY_CAPTCHA_LOAD_ON_INIT_KEY, + 'checkboxHelp' => \__('By default, the widget is only loaded on pages that contain forms. Enable this to load it on every page.', 'eightshift-forms'), + 'checkboxSingleSubmit' => true, + 'checkboxAsToggle' => true, + 'checkboxAsToggleSize' => 'medium', + ], + ], + ], + ]; + } + + /** + * Field list for the "Help" tab — setup steps. + * + * @return array> + */ + public static function getHelpContent(): array + { + return [ + [ + 'component' => 'steps', + 'stepsTitle' => \__('How to get the Friendly Captcha API keys?', 'eightshift-forms'), + 'stepsContent' => [ + // translators: %s will be replaced with the external link. + \sprintf(\__('Visit the Friendly Captcha dashboard.', 'eightshift-forms'), 'https://app.friendlycaptcha.eu/dashboard'), + \__('Create a new application and copy the Site key.', 'eightshift-forms'), + \__('Go to API Keys and create a new API key.', 'eightshift-forms'), + \__('Copy both keys into the fields under the Settings tab or use the global constants.', 'eightshift-forms'), + \__('In the Friendly Captcha dashboard, open your application settings, go to the Protection tab, and set the Widget Mode to Smart.', 'eightshift-forms'), + ], + ], + ]; + } +} diff --git a/src/Captcha/SettingsRecaptcha.php b/src/Captcha/SettingsRecaptcha.php new file mode 100644 index 000000000..a8ecb38a8 --- /dev/null +++ b/src/Captcha/SettingsRecaptcha.php @@ -0,0 +1,356 @@ +labels = $labels; + } + + /** + * Register all the hooks + * + * @return void + */ + public function register(): void + { + \add_filter(self::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, [$this, 'isSettingsGlobalValid']); + } + + /** + * Determine if settings global are valid. + * + * @return boolean + */ + public function isSettingsGlobalValid(): bool + { + if (SettingsCaptcha::getActiveProvider() !== SettingsCaptcha::PROVIDER_GOOGLE) { + return false; + } + + $isUsed = SettingsHelpers::isOptionCheckboxChecked(SettingsCaptcha::SETTINGS_CAPTCHA_USE_KEY, SettingsCaptcha::SETTINGS_CAPTCHA_USE_KEY); + $siteKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), self::SETTINGS_CAPTCHA_SITE_KEY); + $secretKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSecretKey(), self::SETTINGS_CAPTCHA_SECRET_KEY); + $apiKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaApiKey(), self::SETTINGS_CAPTCHA_API_KEY); + $projectIdKey = (bool) SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaProjectIdKey(), self::SETTINGS_CAPTCHA_PROJECT_ID_KEY); + + $isEnterprise = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_ENTERPRISE_KEY, self::SETTINGS_CAPTCHA_ENTERPRISE_KEY); + + if ($isEnterprise) { + if (!$isUsed || !$siteKey || !$apiKey || !$projectIdKey) { + return false; + } + } else { + if (!$isUsed || !$siteKey || !$secretKey) { + return false; + } + } + + return true; + } + + /** + * Field list for the "Settings" tab — API keys + enterprise toggle. + * + * The dispatcher prepends the provider select above these; the fields + * below it swap based on `SETTINGS_CAPTCHA_ENTERPRISE_KEY` in the same + * Corvus-style driver-field pattern. + * + * @return array> + */ + public static function getGeneralContent(): array + { + $isEnterprise = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_ENTERPRISE_KEY, self::SETTINGS_CAPTCHA_ENTERPRISE_KEY); + + return [ + [ + 'component' => 'intro', + // phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings + 'introSubtitle' => \__('Protect your website from spam and abuse using Google\'s reCAPTCHA.
A captcha is a simple task that is easy for humans to do, but difficult for bots.', 'eightshift-forms'), + ], + [ + 'component' => 'checkboxes', + 'checkboxesFieldHideLabel' => true, + 'checkboxesName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_ENTERPRISE_KEY), + 'checkboxesContent' => [ + [ + 'component' => 'checkbox', + 'checkboxLabel' => \__('Use reCAPTCHA Enterprise', 'eightshift-forms'), + 'checkboxIsChecked' => $isEnterprise, + 'checkboxValue' => self::SETTINGS_CAPTCHA_ENTERPRISE_KEY, + 'checkboxSingleSubmit' => true, + 'checkboxAsToggle' => true, + ], + ], + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( + Variables::getGoogleReCaptchaSiteKey(), + self::SETTINGS_CAPTCHA_SITE_KEY, + 'ES_GOOGLE_RECAPTCHA_SITE_KEY', + \__('Site key', 'eightshift-forms'), + ), + ...(!$isEnterprise ? [ + SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( + Variables::getGoogleReCaptchaSecretKey(), + self::SETTINGS_CAPTCHA_SECRET_KEY, + 'ES_GOOGLE_RECAPTCHA_SECRET_KEY', + \__('Secret key', 'eightshift-forms'), + ), + ] : [ + SettingsOutputHelpers::getInputFieldWithGlobalVariable( + Variables::getGoogleReCaptchaProjectIdKey(), + self::SETTINGS_CAPTCHA_PROJECT_ID_KEY, + 'ES_GOOGLE_RECAPTCHA_PROJECT_ID_KEY', + \__('Project ID', 'eightshift-forms'), + ), + SettingsOutputHelpers::getPasswordFieldWithGlobalVariable( + Variables::getGoogleReCaptchaApiKey(), + self::SETTINGS_CAPTCHA_API_KEY, + 'ES_GOOGLE_RECAPTCHA_API_KEY', + \__('API key', 'eightshift-forms'), + ), + ]), + ]; + } + + /** + * Field list for the "Advanced" tab — score, actions, badge, init toggle. + * + * @return array> + */ + public static function getAdvancedContent(): array + { + $isInit = SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY); + + return [ + [ + 'component' => 'checkboxes', + 'checkboxesFieldHideLabel' => true, + 'checkboxesName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY), + 'checkboxesContent' => [ + [ + 'component' => 'checkbox', + 'checkboxLabel' => \__('Hide badge', 'eightshift-forms'), + 'checkboxIsChecked' => SettingsHelpers::isOptionCheckboxChecked(self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY, self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY), + 'checkboxValue' => self::SETTINGS_CAPTCHA_HIDE_BADGE_KEY, + 'checkboxSingleSubmit' => true, + 'checkboxAsToggle' => true, + 'checkboxHelp' => \__('Not recommended, as it is against Google\'s terms of use.', 'eightshift-forms'), + ], + ], + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + [ + 'component' => 'input', + 'inputName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_SCORE_KEY), + 'inputFieldLabel' => \__('"Spam unlikely" threshold', 'eightshift-forms'), + 'inputFieldHelp' => \__('The level above which a submission is not considered spam. Should be between 0.1 and 1.0.
In most cases, a user will receive as core between 0.8 and 0.9.', 'eightshift-forms'), + 'inputType' => 'number', + 'inputValue' => SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_SCORE_KEY), + 'inputMin' => 0.1, + 'inputMax' => 1, + 'inputStep' => 0.1, + 'inputIsNumber' => true, + 'inputPlaceholder' => self::SETTINGS_CAPTCHA_SCORE_DEFAULT_KEY, + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + [ + 'component' => 'input', + 'inputName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_SUBMIT_ACTION_KEY), + 'inputFieldLabel' => \__('"On submit" action name', 'eightshift-forms'), + 'inputFieldHelp' => \__('Name of the action sent to reCAPTCHA on form submission.', 'eightshift-forms'), + 'inputType' => 'text', + 'inputValue' => SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_SUBMIT_ACTION_KEY), + 'inputPlaceholder' => self::SETTINGS_CAPTCHA_SUBMIT_ACTION_DEFAULT_KEY, + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + [ + 'component' => 'checkboxes', + 'checkboxesFieldHideLabel' => true, + 'checkboxesName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY), + 'checkboxesContent' => [ + [ + 'component' => 'checkbox', + 'checkboxLabel' => \__('Load Captcha on website load', 'eightshift-forms'), + 'checkboxIsChecked' => $isInit, + 'checkboxValue' => self::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, + 'checkboxHelp' => \__('By default, Captcha is only loaded on pages that contain forms. However, with this option, you can load Captcha on every page.', 'eightshift-forms'), + 'checkboxSingleSubmit' => true, + 'checkboxAsToggle' => true, + 'checkboxAsToggleSize' => 'medium', + ], + ], + ], + ...($isInit ? [ + [ + 'component' => 'input', + 'inputName' => SettingsHelpers::getOptionName(self::SETTINGS_CAPTCHA_INIT_ACTION_KEY), + 'inputFieldLabel' => \__('Action name', 'eightshift-forms'), + 'inputFieldHelp' => \__('Name of the action sent to reCAPTCHA when Captcha is loaded on every page.', 'eightshift-forms'), + 'inputType' => 'text', + 'inputValue' => SettingsHelpers::getOptionValue(self::SETTINGS_CAPTCHA_INIT_ACTION_KEY), + 'inputPlaceholder' => self::SETTINGS_CAPTCHA_INIT_ACTION_DEFAULT_KEY, + ], + ] : []), + ]; + } + + /** + * Field list for the "Help" tab — setup steps for free and enterprise keys. + * + * @return array> + */ + public static function getHelpContent(): array + { + return [ + [ + 'component' => 'steps', + 'stepsTitle' => \__('How to get the Free reCAPTCHA API key?', 'eightshift-forms'), + 'stepsContent' => [ + // translators: %s will be replaced with the external link. + \sprintf(\__('Visit this link.', 'eightshift-forms'), 'https://www.google.com/recaptcha/admin/create'), + \__('Configure all the options. Make sure to select reCaptcha version 3!', 'eightshift-forms'), + \__('Copy the API key into the field under the API tab or use the global constant.', 'eightshift-forms'), + ], + ], + [ + 'component' => 'divider', + 'dividerExtraVSpacing' => true, + ], + [ + 'component' => 'steps', + 'stepsTitle' => \__('How to get the Enterprise reCAPTCHA API key?', 'eightshift-forms'), + 'stepsContent' => [ + // translators: %s will be replaced with the external link. + \sprintf(\__('Visit Google Cloud Console.', 'eightshift-forms'), 'https://console.cloud.google.com/'), + \__('Create a new project and set that project as Project ID.', 'eightshift-forms'), + \__('Search and go to reCAPTCHA product.', 'eightshift-forms'), + \__('You will probably need to set billing service for this product.', 'eightshift-forms'), + \__('Create a new key and set that key as Site key.', 'eightshift-forms'), + // translators: %s will be replaced with the website domain. + \sprintf(\__('Limit the key to your website domain. Domain: %s (exact, no trailing slash and protocol).', 'eightshift-forms'), \preg_replace("(^https?://)", "", \site_url())), + \__('Search and go to API & Services product.', 'eightshift-forms'), + \__('Go to Credentials section and create a new API key.', 'eightshift-forms'), + // translators: %s will be replaced with the website domain. + \sprintf(\__('Create a new key for Website, add restrictions to your website domain %s (exact, no trailing slash, with protocol) and set API restrictions to reCAPTCHA Enterprise.', 'eightshift-forms'), \esc_url(\site_url())), + \__('Set that key as API key.', 'eightshift-forms'), + ], + ], + ]; + } +} diff --git a/src/Enqueue/Blocks/EnqueueBlocks.php b/src/Enqueue/Blocks/EnqueueBlocks.php index 82a55a8cb..7a2a57801 100644 --- a/src/Enqueue/Blocks/EnqueueBlocks.php +++ b/src/Enqueue/Blocks/EnqueueBlocks.php @@ -15,11 +15,15 @@ use EightshiftForms\Enrichment\EnrichmentInterface; use EightshiftForms\Enrichment\SettingsEnrichment; use EightshiftForms\Settings\SettingsSettings; +use EightshiftForms\Captcha\FriendlyCaptcha; use EightshiftForms\Captcha\SettingsCaptcha; +use EightshiftForms\Captcha\SettingsFriendlyCaptcha; +use EightshiftForms\Captcha\SettingsRecaptcha; use EightshiftForms\CustomPostType\Result; use EightshiftForms\CustomPostType\Forms; use EightshiftForms\Enqueue\SharedEnqueue; -use EightshiftForms\Enqueue\Captcha\EnqueueCaptcha; +use EightshiftForms\Enqueue\Captcha\EnqueueRecaptcha; +use EightshiftForms\Enqueue\Captcha\EnqueueFriendlyCaptcha; use EightshiftForms\Geolocation\GeolocationInterface; use EightshiftForms\Geolocation\SettingsGeolocation; use EightshiftForms\Hooks\FiltersOutputMock; @@ -329,17 +333,37 @@ public function enqueueBlockFrontendScript(): void 'location' => $this->geolocation->getUsersGeolocation(), ]; - // Check if Captcha data is set and valid. + // Build the single captcha payload. `type` discriminates the provider so the JS + // layer can render the right widget without probing multiple top-level keys. if (\apply_filters(SettingsCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { - $output['captcha'] = [ - 'isUsed' => true, - 'isEnterprise' => SettingsHelpers::isOptionCheckboxChecked(SettingsCaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY, SettingsCaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY), - 'siteKey' => SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), SettingsCaptcha::SETTINGS_CAPTCHA_SITE_KEY), - 'submitAction' => SettingsHelpers::getOptionValue(SettingsCaptcha::SETTINGS_CAPTCHA_SUBMIT_ACTION_KEY) ?: SettingsCaptcha::SETTINGS_CAPTCHA_SUBMIT_ACTION_DEFAULT_KEY, // phpcs:ignore WordPress.PHP.DisallowShortTernary.Found - 'initAction' => SettingsHelpers::getOptionValue(SettingsCaptcha::SETTINGS_CAPTCHA_INIT_ACTION_KEY) ?: SettingsCaptcha::SETTINGS_CAPTCHA_INIT_ACTION_DEFAULT_KEY, // phpcs:ignore WordPress.PHP.DisallowShortTernary.Found - 'loadOnInit' => SettingsHelpers::isOptionCheckboxChecked(SettingsCaptcha::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, SettingsCaptcha::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY), - 'hideBadge' => SettingsHelpers::isOptionCheckboxChecked(SettingsCaptcha::SETTINGS_CAPTCHA_HIDE_BADGE_KEY, SettingsCaptcha::SETTINGS_CAPTCHA_HIDE_BADGE_KEY), - ]; + $provider = SettingsCaptcha::getActiveProvider(); + + switch ($provider) { + case SettingsCaptcha::PROVIDER_FRIENDLY: + $output['captcha'] = [ + 'isUsed' => true, + 'type' => SettingsCaptcha::PROVIDER_FRIENDLY, + 'siteKey' => SettingsHelpers::getOptionWithConstant( + Variables::getFriendlyCaptchaSiteKey(), + SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_SITE_KEY + ), + 'endpoint' => FriendlyCaptcha::getEndpoint(), + 'loadOnInit' => SettingsHelpers::isOptionCheckboxChecked(SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_LOAD_ON_INIT_KEY, SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_LOAD_ON_INIT_KEY), + ]; + break; + default: + $output['captcha'] = [ + 'isUsed' => true, + 'type' => SettingsCaptcha::PROVIDER_GOOGLE, + 'isEnterprise' => SettingsHelpers::isOptionCheckboxChecked(SettingsRecaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY, SettingsRecaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY), + 'siteKey' => SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), SettingsRecaptcha::SETTINGS_CAPTCHA_SITE_KEY), + 'submitAction' => SettingsHelpers::getOptionValue(SettingsRecaptcha::SETTINGS_CAPTCHA_SUBMIT_ACTION_KEY) ?: SettingsRecaptcha::SETTINGS_CAPTCHA_SUBMIT_ACTION_DEFAULT_KEY, // phpcs:ignore WordPress.PHP.DisallowShortTernary.Found + 'initAction' => SettingsHelpers::getOptionValue(SettingsRecaptcha::SETTINGS_CAPTCHA_INIT_ACTION_KEY) ?: SettingsRecaptcha::SETTINGS_CAPTCHA_INIT_ACTION_DEFAULT_KEY, // phpcs:ignore WordPress.PHP.DisallowShortTernary.Found + 'loadOnInit' => SettingsHelpers::isOptionCheckboxChecked(SettingsRecaptcha::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY, SettingsRecaptcha::SETTINGS_CAPTCHA_LOAD_ON_INIT_KEY), + 'hideBadge' => SettingsHelpers::isOptionCheckboxChecked(SettingsRecaptcha::SETTINGS_CAPTCHA_HIDE_BADGE_KEY, SettingsRecaptcha::SETTINGS_CAPTCHA_HIDE_BADGE_KEY), + ]; + break; + } } else { $output['captcha'] = [ 'isUsed' => false, @@ -364,20 +388,27 @@ public function enqueueBlockFrontendScript(): void */ protected function getFrontendScriptDependencies(): array { - if (!\apply_filters(SettingsCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { - return []; - } + $output = []; $scriptsDependency = HooksHelpers::getFilterName(['scripts', 'dependency', 'blocksFrontend']); - $scriptsDependencyOutput = []; if (\has_filter($scriptsDependency)) { - $scriptsDependencyOutput = \apply_filters($scriptsDependency, []); + $output = \apply_filters($scriptsDependency, []); } - return [ - "{$this->getAssetsPrefix()}-" . EnqueueCaptcha::CAPTCHA_ENQUEUE_HANDLE, - ...$scriptsDependencyOutput, - ]; + switch (SettingsCaptcha::getActiveProvider()) { + case SettingsCaptcha::PROVIDER_FRIENDLY: + if (\apply_filters(SettingsFriendlyCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { + $output[] = "{$this->getAssetsPrefix()}-" . EnqueueFriendlyCaptcha::FRIENDLY_CAPTCHA_ENQUEUE_HANDLE; + } + break; + default: + if (\apply_filters(SettingsRecaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { + $output[] = "{$this->getAssetsPrefix()}-" . EnqueueRecaptcha::CAPTCHA_ENQUEUE_HANDLE; + } + break; + } + + return $output; } } diff --git a/src/Enqueue/Captcha/EnqueueFriendlyCaptcha.php b/src/Enqueue/Captcha/EnqueueFriendlyCaptcha.php new file mode 100644 index 000000000..d7980992f --- /dev/null +++ b/src/Enqueue/Captcha/EnqueueFriendlyCaptcha.php @@ -0,0 +1,118 @@ + List of all the script dependencies. + */ + protected function getFrontendScriptDependencies(): array + { + if (!\apply_filters(SettingsFriendlyCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { + return []; + } + + $scriptsDependency = HooksHelpers::getFilterName(['scripts', 'dependency', 'friendlyCaptcha']); + $scriptsDependencyOutput = []; + + if (\has_filter($scriptsDependency)) { + $scriptsDependencyOutput = \apply_filters($scriptsDependency, []); + } + + return $scriptsDependencyOutput; + } + + /** + * Method that returns frontend script for Friendly Captcha if settings are correct. + * + * @return void + */ + public function enqueueScriptsFriendlyCaptcha(): void + { + // Check if Friendly Captcha data is set and valid. + $isSettingsGlobalValid = \apply_filters(SettingsFriendlyCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false); + + // Bailout if settings are not ok. + if (!$isSettingsGlobalValid) { + return; + } + + $handle = "{$this->getAssetsPrefix()}-" . self::FRIENDLY_CAPTCHA_ENQUEUE_HANDLE; + + \wp_register_script( + $handle, + 'https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.2.0/site.min.js', + $this->getFrontendScriptDependencies(), + $this->getAssetsVersion(), + \is_wp_version_compatible('6.3') ? $this->scriptArgs() : $this->scriptInFooter() + ); + \wp_enqueue_script($handle); + } + + /** + * Load script 'defer' or 'async'. + * + * @return string Whether to enqueue the script normally, with defer or async. + */ + protected function scriptStrategy(): string + { + return 'defer'; + } + + /** + * Method that returns assets name used to prefix asset handlers. + * + * @return string + */ + public function getAssetsPrefix(): string + { + return Config::MAIN_PLUGIN_ENQUEUE_ASSETS_PREFIX; + } + + /** + * Method that returns assets version for versioning asset handlers. + * + * @return string + */ + public function getAssetsVersion(): string + { + return Helpers::getPluginVersion(); + } +} diff --git a/src/Enqueue/Captcha/EnqueueCaptcha.php b/src/Enqueue/Captcha/EnqueueRecaptcha.php similarity index 85% rename from src/Enqueue/Captcha/EnqueueCaptcha.php rename to src/Enqueue/Captcha/EnqueueRecaptcha.php index 04f7c8eb4..8813e6e3c 100644 --- a/src/Enqueue/Captcha/EnqueueCaptcha.php +++ b/src/Enqueue/Captcha/EnqueueRecaptcha.php @@ -12,16 +12,16 @@ use EightshiftForms\Helpers\SettingsHelpers; use EightshiftForms\Hooks\Variables; -use EightshiftForms\Captcha\SettingsCaptcha; +use EightshiftForms\Captcha\SettingsRecaptcha; use EightshiftForms\Config\Config; use EightshiftForms\Helpers\HooksHelpers; use EightshiftFormsVendor\EightshiftLibs\Enqueue\Theme\AbstractEnqueueTheme; use EightshiftFormsVendor\EightshiftLibs\Helpers\Helpers; /** - * Class EnqueueCaptcha + * Class EnqueueRecaptcha */ -class EnqueueCaptcha extends AbstractEnqueueTheme +class EnqueueRecaptcha extends AbstractEnqueueTheme { /** * Captcha enqueue handle. @@ -47,7 +47,7 @@ public function register(): void */ protected function getFrontendScriptDependencies(): array { - if (!\apply_filters(SettingsCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { + if (!\apply_filters(SettingsRecaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false)) { return []; } @@ -69,7 +69,7 @@ protected function getFrontendScriptDependencies(): array public function enqueueScriptsCaptcha(): void { // Check if Captcha data is set and valid. - $isSettingsGlobalValid = \apply_filters(SettingsCaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false); + $isSettingsGlobalValid = \apply_filters(SettingsRecaptcha::FILTER_SETTINGS_GLOBAL_IS_VALID_NAME, false); // Bailout if settings are not ok. if (!$isSettingsGlobalValid) { @@ -78,9 +78,9 @@ public function enqueueScriptsCaptcha(): void $handle = "{$this->getAssetsPrefix()}-" . self::CAPTCHA_ENQUEUE_HANDLE; - $siteKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), SettingsCaptcha::SETTINGS_CAPTCHA_SITE_KEY); + $siteKey = SettingsHelpers::getOptionWithConstant(Variables::getGoogleReCaptchaSiteKey(), SettingsRecaptcha::SETTINGS_CAPTCHA_SITE_KEY); - $isEnterprise = SettingsHelpers::isOptionCheckboxChecked(SettingsCaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY, SettingsCaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY); + $isEnterprise = SettingsHelpers::isOptionCheckboxChecked(SettingsRecaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY, SettingsRecaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY); $url = "https://www.google.com/recaptcha/api.js?render={$siteKey}"; diff --git a/src/Hooks/Filters.php b/src/Hooks/Filters.php index 0f876f673..68237cd6b 100644 --- a/src/Hooks/Filters.php +++ b/src/Hooks/Filters.php @@ -31,6 +31,8 @@ use EightshiftForms\Troubleshooting\SettingsDebug; use EightshiftForms\Troubleshooting\SettingsFallback; use EightshiftForms\Captcha\SettingsCaptcha; +use EightshiftForms\Captcha\SettingsFriendlyCaptcha; +use EightshiftForms\Captcha\SettingsRecaptcha; use EightshiftForms\General\SettingsGeneral; use EightshiftForms\Integrations\Calculator\SettingsCalculator; use EightshiftForms\Integrations\Corvus\SettingsCorvus; @@ -166,6 +168,7 @@ private static function getPublicFilters(): array 'dependency' => [ 'admin', 'captcha', + 'friendlyCaptcha', 'blocksEditor', 'blocksFrontend', ], @@ -422,11 +425,16 @@ private static function getSettingsNonTranslatableNames(): array { return [ SettingsCaptcha::SETTINGS_CAPTCHA_USE_KEY, - SettingsCaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY, - SettingsCaptcha::SETTINGS_CAPTCHA_SITE_KEY, - SettingsCaptcha::SETTINGS_CAPTCHA_SECRET_KEY, - SettingsCaptcha::SETTINGS_CAPTCHA_PROJECT_ID_KEY, - SettingsCaptcha::SETTINGS_CAPTCHA_API_KEY, + SettingsCaptcha::SETTINGS_CAPTCHA_PROVIDER_KEY, + SettingsRecaptcha::SETTINGS_CAPTCHA_ENTERPRISE_KEY, + SettingsRecaptcha::SETTINGS_CAPTCHA_SITE_KEY, + SettingsRecaptcha::SETTINGS_CAPTCHA_SECRET_KEY, + SettingsRecaptcha::SETTINGS_CAPTCHA_PROJECT_ID_KEY, + SettingsRecaptcha::SETTINGS_CAPTCHA_API_KEY, + + SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_SITE_KEY, + SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_API_KEY, + SettingsFriendlyCaptcha::SETTINGS_FRIENDLY_CAPTCHA_USE_EU_ENDPOINT_KEY, SettingsGeolocation::SETTINGS_GEOLOCATION_USE_KEY, diff --git a/src/Hooks/FiltersSettingsBuilder.php b/src/Hooks/FiltersSettingsBuilder.php index 34854fb48..0bf3484bc 100644 --- a/src/Hooks/FiltersSettingsBuilder.php +++ b/src/Hooks/FiltersSettingsBuilder.php @@ -156,8 +156,7 @@ public function getSettingsFiltersData(): array 'use' => SettingsCaptcha::SETTINGS_CAPTCHA_USE_KEY, 'labels' => [ 'title' => \__('Spam prevention', 'eightshift-forms'), - 'desc' => \__('Prevent misuse of your forms by adding Google ReCaptcha.', 'eightshift-forms'), - 'externalLink' => 'https://www.google.com/recaptcha/about/', + 'desc' => \__('Prevent misuse of your forms by adding a captcha provider.', 'eightshift-forms'), ], ], SettingsGeolocation::SETTINGS_TYPE_KEY => [ diff --git a/src/Hooks/Variables.php b/src/Hooks/Variables.php index 7936127d7..a63ecf871 100644 --- a/src/Hooks/Variables.php +++ b/src/Hooks/Variables.php @@ -115,6 +115,26 @@ public static function getGoogleReCaptchaProjectIdKey() return \defined('ES_GOOGLE_RECAPTCHA_PROJECT_ID_KEY') ? \ES_GOOGLE_RECAPTCHA_PROJECT_ID_KEY : ''; } + /** + * Get Friendly Captcha site key. + * + * @return string + */ + public static function getFriendlyCaptchaSiteKey() + { + return \defined('ES_FRIENDLY_CAPTCHA_SITE_KEY') ? \ES_FRIENDLY_CAPTCHA_SITE_KEY : ''; + } + + /** + * Get Friendly Captcha API key. + * + * @return string + */ + public static function getFriendlyCaptchaApiKey() + { + return \defined('ES_FRIENDLY_CAPTCHA_API_KEY') ? \ES_FRIENDLY_CAPTCHA_API_KEY : ''; + } + /** * Get forms geolocation ip. Default: empty. * diff --git a/src/Labels/Labels.php b/src/Labels/Labels.php index 716082ea6..9cfc500c4 100644 --- a/src/Labels/Labels.php +++ b/src/Labels/Labels.php @@ -425,6 +425,13 @@ private function getCaptchaLabels(): array 'captchaScoreSpam' => \__('The request was marked as a potential spam request. Please try again.', 'eightshift-forms'), 'captchaError' => \__('Spam prevention system encountered an error. Please try again.', 'eightshift-forms'), 'captchaSuccess' => \__('Success', 'eightshift-forms'), + 'friendlyCaptchaBadRequest' => \__('Spam prevention system encountered an error. Friendly Captcha request is invalid or malformed.', 'eightshift-forms'), + 'friendlyCaptchaError' => \__('Spam prevention system encountered an error. Please try again.', 'eightshift-forms'), + 'friendlyCaptchaSuccess' => \__('Success', 'eightshift-forms'), + 'friendlyCaptchaAuthError' => \__('Spam prevention system is not configured correctly. Please get in touch with the website administrator to resolve this issue.', 'eightshift-forms'), + 'friendlyCaptchaInvalidSolution' => \__('The request was marked as a potential spam request. Please try again.', 'eightshift-forms'), + 'friendlyCaptchaTimeoutOrDuplicate' => \__('Spam prevention check timed out or was reused. Please try again.', 'eightshift-forms'), + 'friendlyCaptchaHttpError' => \__('Spam prevention service is currently unavailable. Please try again in a moment.', 'eightshift-forms'), ]; } diff --git a/src/Troubleshooting/SettingsFallback.php b/src/Troubleshooting/SettingsFallback.php index 04a270c47..df7a91c1a 100644 --- a/src/Troubleshooting/SettingsFallback.php +++ b/src/Troubleshooting/SettingsFallback.php @@ -97,6 +97,13 @@ class SettingsFallback implements ServiceInterface, SettingsFallbackDataInterfac public const SETTINGS_FALLBACK_FLAG_CAPTCHA_SUCCESS = 'captchaSuccess'; public const SETTINGS_FALLBACK_FLAG_CAPTCHA_DEBUG_SKIP_CHECK = 'captchaDebugSkipCheck'; + public const SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_OUTPUT_ERROR = 'friendlyCaptchaOutputError'; + public const SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_HTTP_ERROR = 'friendlyCaptchaHttpError'; + public const SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_AUTH_ERROR = 'friendlyCaptchaAuthError'; + public const SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_BAD_REQUEST = 'friendlyCaptchaBadRequest'; + public const SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_INVALID_SOLUTION = 'friendlyCaptchaInvalidSolution'; + public const SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_TIMEOUT_OR_DUPLICATE = 'friendlyCaptchaTimeoutOrDuplicate'; + public const SETTINGS_FALLBACK_FLAG_GEOLOCATION_FEATURE_DISABLED = 'geolocationFeatureDisabled'; public const SETTINGS_FALLBACK_FLAG_GEOLOCATION_MALFORMED_DECRYPT_DATA = 'geolocationMalformedDecryptData'; public const SETTINGS_FALLBACK_FLAG_GEOLOCATION_DETECTION_FAILED = 'geolocationDetectionFailed'; @@ -554,6 +561,30 @@ private function getFlagsList(): array 'label' => \__('Captcha debug skip check is active.', 'eightshift-forms'), 'isRecommended' => true, ], + self::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_OUTPUT_ERROR => [ + 'label' => \__('Friendly Captcha returned an error response.', 'eightshift-forms'), + 'isRecommended' => true, + ], + self::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_HTTP_ERROR => [ + 'label' => \__('Friendly Captcha siteverify request returned a non-success HTTP status.', 'eightshift-forms'), + 'isRecommended' => true, + ], + self::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_AUTH_ERROR => [ + 'label' => \__('Friendly Captcha API key is missing or invalid.', 'eightshift-forms'), + 'isRecommended' => true, + ], + self::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_BAD_REQUEST => [ + 'label' => \__('Friendly Captcha rejected the request as malformed.', 'eightshift-forms'), + 'isRecommended' => true, + ], + self::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_INVALID_SOLUTION => [ + 'label' => \__('Friendly Captcha solution failed validation.', 'eightshift-forms'), + 'isRecommended' => false, + ], + self::SETTINGS_FALLBACK_FLAG_FRIENDLY_CAPTCHA_TIMEOUT_OR_DUPLICATE => [ + 'label' => \__('Friendly Captcha solution expired or was already used.', 'eightshift-forms'), + 'isRecommended' => false, + ], // Geolocation. self::SETTINGS_FALLBACK_FLAG_GEOLOCATION_FEATURE_DISABLED => [