diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index d339ea6..e322aa2 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -4,10 +4,18 @@ import { } from "#settings/procaptcha/procaptchaAccount.js"; import { z, type ZodType } from "zod"; +/** + * Only the parts of the site response the statistics page actually renders: + * the monthly captcha counts and the account tier the traffic chart gates on. + * + * `settings` is deliberately absent. Zod strips keys a schema does not + * declare, so whatever shape the portal sends for the captcha settings — and + * `frictionlessThreshold` has already changed shape once — is ignored rather + * than validated. The whole response is a single parse, so a field the page + * does not display has no business being able to fail it. + */ export interface ProcaptchaSite { account: ProcaptchaAccount; - name: string; - settings: SiteSettings; monthlyUsage: { limit: number; image: CaptchaUsage; @@ -15,26 +23,12 @@ export interface ProcaptchaSite { }; } -export interface SiteSettings { - frictionlessThreshold: number; - powDifficulty: number; - captchaType: string; - domains: string[]; -} - export interface CaptchaUsage { submissions: number; verifications: number; total: number; } -export const siteSettingsSchema = z.object({ - frictionlessThreshold: z.number(), - powDifficulty: z.number(), - captchaType: z.string(), - domains: z.string().array(), -}) satisfies ZodType; - export const captchaUsageSchema = z.object({ submissions: z.number(), verifications: z.number(), @@ -43,8 +37,6 @@ export const captchaUsageSchema = z.object({ export const procaptchaSiteSchema = z.object({ account: procaptchaAccountSchema, - name: z.string(), - settings: siteSettingsSchema, monthlyUsage: z.object({ limit: z.number(), image: captchaUsageSchema, diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index e2fc253..9099a97 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -4,7 +4,6 @@ import { AppStatusComponentProperties, StatCurrentState, } from "./appStatusComponent.js"; -import { ListComponent, ListComponentProperties } from "./listComponent.js"; import { TrafficAnalyticsComponent, TrafficAnalyticsComponentProperties, @@ -21,10 +20,7 @@ import { import type Logger from "#utils/logger/logger.js"; import { type Config, ConfigClass } from "#settings/statistics/config.js"; import CaptchaUsageNumberUtils from "#settings/statistics/captchaUsage/captchaUsageNumberUtils.js"; -import type { - ProcaptchaSite, - SiteSettings, -} from "#settings/procaptcha/procaptchaSite.js"; +import type { ProcaptchaSite } from "#settings/procaptcha/procaptchaSite.js"; import type { ProcaptchaAccount } from "#settings/procaptcha/procaptchaAccount.js"; import { @@ -39,9 +35,6 @@ interface AppComponentProperties { interface AppState { statState: AppStatusComponentProperties; usageInfo: CaptchaUsageComponentProperties; - accountInformation: ListComponentProperties; - captchaSettings: ListComponentProperties; - domains: ListComponentProperties; trafficData: TrafficAnalyticsComponentProperties; } @@ -95,45 +88,6 @@ class AppComponent extends React.Component { }, labels: this.config.getUsageLabels(), }, - accountInformation: { - title: this.config.getAccountLabels().title, - icon: "icon-[material-symbols--account-circle]", - items: [ - { - label: this.config.getAccountLabels().tier, - value: "...", - }, - { - label: this.config.getAccountLabels().name, - value: "...", - }, - ], - }, - captchaSettings: { - title: this.config.getCaptchaSettingsLabels().title, - icon: "icon-[material-symbols--settings]", - items: [ - { - label: this.config.getCaptchaSettingsLabels().type, - value: "...", - }, - { - label: this.config.getCaptchaSettingsLabels() - .frictionlessThreshold, - value: "...", - }, - { - label: this.config.getCaptchaSettingsLabels() - .powDifficulty, - value: "...", - }, - ], - }, - domains: { - title: this.config.getDomainLabels().title, - icon: "icon-[material-symbols--domain]", - items: [], - }, trafficData: { accountTier: "", logger: this.logger, @@ -164,22 +118,9 @@ class AppComponent extends React.Component { })); } - protected refreshUserData(site: ProcaptchaSite): void { + protected refreshUsage(site: ProcaptchaSite): void { this.setState((actualState) => ({ ...actualState, - accountInformation: { - ...actualState.accountInformation, - items: [ - { - label: this.config.getAccountLabels().tier, - value: site.account.tier.toUpperCase(), - }, - { - label: this.config.getAccountLabels().name, - value: site.name, - }, - ], - }, usageInfo: { ...actualState.usageInfo, limits: { @@ -199,83 +140,6 @@ class AppComponent extends React.Component { })); } - protected getPowDifficultyLabel(powDifficulty: number): string { - const levelLabels = this.config.getCaptchaSettingsLabels().level; - - if (powDifficulty === 4) { - return levelLabels.normal; - } - - return powDifficulty < 4 ? levelLabels.low : levelLabels.high; - } - - protected getFrictionlessThresholdLabel( - frictionlessThreshold: number, - ): string { - const levelLabels = this.config.getCaptchaSettingsLabels().level; - - if (frictionlessThreshold >= 0.4 && frictionlessThreshold <= 0.6) { - return levelLabels.normal; - } - - return frictionlessThreshold < 0.4 ? levelLabels.high : levelLabels.low; - } - - protected getTypeLabel(type: string): string { - const typeLabels = this.config.getCaptchaSettingsLabels().types; - - switch (type) { - case "image": - return typeLabels.image; - case "pow": - return typeLabels.proofOfWork; - default: - return typeLabels.frictionless; - } - } - - protected refreshSiteSettings(siteSettings: SiteSettings): void { - const domains = siteSettings.domains - .filter((domain) => "*" !== domain) - .map((domain, index) => { - return { - label: "#" + (index + 1), - value: domain, - }; - }); - - this.setState((actualState) => ({ - ...actualState, - captchaSettings: { - ...actualState.captchaSettings, - items: [ - { - label: this.config.getCaptchaSettingsLabels().type, - value: this.getTypeLabel(siteSettings.captchaType), - }, - { - label: this.config.getCaptchaSettingsLabels() - .frictionlessThreshold, - value: this.getFrictionlessThresholdLabel( - siteSettings.frictionlessThreshold, - ), - }, - { - label: this.config.getCaptchaSettingsLabels() - .powDifficulty, - value: this.getPowDifficultyLabel( - siteSettings.powDifficulty, - ), - }, - ], - }, - domains: { - ...actualState.domains, - items: domains, - }, - })); - } - protected refreshTrafficData(account: ProcaptchaAccount): void { this.setState((actualState) => ({ ...actualState, @@ -295,8 +159,7 @@ class AppComponent extends React.Component { ); if (site) { - this.refreshUserData(site); - this.refreshSiteSettings(site.settings); + this.refreshUsage(site); this.refreshTrafficData(site.account); this.markAsLoaded(); @@ -318,14 +181,7 @@ class AppComponent extends React.Component { } public render() { - const { - statState, - usageInfo, - accountInformation, - captchaSettings, - domains, - trafficData, - } = this.state; + const { statState, usageInfo, trafficData } = this.state; return (
@@ -343,21 +199,6 @@ class AppComponent extends React.Component { image={usageInfo.image} pow={usageInfo.pow} /> - - - ; -} - -class ListComponent extends React.Component { - render() { - const { title, icon, items } = this.props; - - return ( - -
- {items.map((item, index) => ( - -

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- ); - } -} - -export { ListComponent, ListComponentProperties }; diff --git a/assets/src/settings/statistics/config.ts b/assets/src/settings/statistics/config.ts index 6e5d621..5bf8d9c 100644 --- a/assets/src/settings/statistics/config.ts +++ b/assets/src/settings/statistics/config.ts @@ -1,11 +1,5 @@ import Collection from "./collection.js"; -interface AccountLabels { - title: string; - name: string; - tier: string; -} - interface UsageLabels { title: string; total: string; @@ -20,27 +14,6 @@ interface StateLabels { loading: string; } -interface CaptchaSettingsLabels { - title: string; - type: string; - frictionlessThreshold: string; - powDifficulty: string; - level: { - low: string; - normal: string; - high: string; - }; - types: { - proofOfWork: string; - image: string; - frictionless: string; - }; -} - -interface DomainLabels { - title: string; -} - interface TrafficDataLabels { title: string; chartTitle: string; @@ -55,16 +28,10 @@ interface Config { getSecretKey(): string; - getAccountLabels(): AccountLabels; - getUsageLabels(): UsageLabels; getStateLabels(): StateLabels; - getCaptchaSettingsLabels(): CaptchaSettingsLabels; - - getDomainLabels(): DomainLabels; - getTrafficDataLabels(): TrafficDataLabels; getCallToUpgradeElementMarkup(): string; @@ -101,16 +68,6 @@ class ConfigClass implements Config { return this.data.getString("secretKey"); } - public getAccountLabels(): AccountLabels { - const accountLabels = this.data.getSubCollection("accountLabels"); - - return { - title: accountLabels.getString("title"), - name: accountLabels.getString("name"), - tier: accountLabels.getString("tier"), - }; - } - public getUsageLabels(): UsageLabels { const usageLabels = this.data.getSubCollection("usageLabels"); @@ -137,42 +94,6 @@ class ConfigClass implements Config { }; } - public getCaptchaSettingsLabels(): CaptchaSettingsLabels { - const captchaSettingsLabels = this.data.getSubCollection( - "captchaSettingsLabels", - ); - - const level = captchaSettingsLabels.getSubCollection("level"); - const types = captchaSettingsLabels.getSubCollection("types"); - - return { - title: captchaSettingsLabels.getString("title"), - type: captchaSettingsLabels.getString("type"), - frictionlessThreshold: captchaSettingsLabels.getString( - "frictionlessThreshold", - ), - powDifficulty: captchaSettingsLabels.getString("powDifficulty"), - level: { - low: level.getString("low"), - normal: level.getString("normal"), - high: level.getString("high"), - }, - types: { - proofOfWork: types.getString("proofOfWork"), - image: types.getString("image"), - frictionless: types.getString("frictionless"), - }, - }; - } - - public getDomainLabels(): DomainLabels { - const domainLabels = this.data.getSubCollection("domainLabels"); - - return { - title: domainLabels.getString("title"), - }; - } - public getTrafficDataLabels(): TrafficDataLabels { const trafficDataLabels = this.data.getSubCollection("trafficDataLabels"); @@ -196,11 +117,4 @@ class ConfigClass implements Config { } } -export { - Config, - ConfigClass, - AccountLabels, - UsageLabels, - StateLabels, - TrafficDataLabels, -}; +export { Config, ConfigClass, UsageLabels, StateLabels, TrafficDataLabels }; diff --git a/data-for-tests/mu-plugin.php b/data-for-tests/mu-plugin.php index 5e13c95..ecc3b2a 100644 --- a/data-for-tests/mu-plugin.php +++ b/data-for-tests/mu-plugin.php @@ -2,6 +2,35 @@ define( 'PROSOPO_PROCAPTCHA_ALLOW_BYPASS', true ); +/** + * Reserved Prosopo test site key, see https://docs.prosopo.io/en/basics/test-keys/ + * + * The reserved keys are constant across all the Prosopo environments, require no registration, + * and their requests aren't billed, so the test suite doesn't depend on any personal account. + * + * The always-fail key is the one we need: the suite submits its own tokens + * ('bypass' and 'wrong', see the ALLOW_BYPASS constant above), so the widget must not + * emit a token of its own and overwrite them. The always-pass key would do exactly that. + * + * The secret is ignored for the reserved keys, but it must be non-empty + * for the plugin to consider itself configured. + */ +define( 'PROSOPO_PROCAPTCHA_TEST_SITE_KEY', '5ETtechmZkn3CUVeJX7Z511oiuiu742aHLm91D5ZZw4fqoAG' ); +define( 'PROSOPO_PROCAPTCHA_TEST_SECRET_KEY', 'test-secret-key-is-ignored-for-reserved-site-keys' ); + +add_filter( + 'option_prosopo-procaptcha__settings', + function ( $settings ) { + $settings = is_array( $settings ) ? $settings : array(); + + $settings['site_key'] = PROSOPO_PROCAPTCHA_TEST_SITE_KEY; + $settings['secret_key'] = PROSOPO_PROCAPTCHA_TEST_SECRET_KEY; + + return $settings; + }, + 999 +); + add_filter( 'comment_flood_filter', '__return_false', 999 ); add_filter( 'pre_wp_mail', '__return_true' ); add_filter( 'bbp_bypass_check_for_flood', '__return_true' ); diff --git a/php-tools/code-quality/stubs/Jet_Form_Builder_Captcha.php b/php-tools/code-quality/stubs/Jet_Form_Builder_Captcha.php new file mode 100644 index 0000000..4e1e796 --- /dev/null +++ b/php-tools/code-quality/stubs/Jet_Form_Builder_Captcha.php @@ -0,0 +1,93 @@ + + */ + protected $options; + + abstract public function get_id(): string; + + abstract public function get_title(): string; + + /** + * @param array $request + * + * @return void + */ + abstract public function verify( array $request ); + + abstract protected function render(): string; + + public function get_output(): string { + return $this->render(); + } + + /** + * @param array $options + */ + public function sanitize_options( array $options ): Base_Captcha { + return $this; + } + + /** + * @return string + */ + public function rep_item_id() { + return $this->get_id(); + } + + /** + * @return array + */ + public function to_array(): array { + return array( + 'label' => $this->get_title(), + 'value' => $this->get_id(), + ); + } + } + + interface Captcha_Settings_From_Options { + + /** + * @param array $post_request + * + * @return array + */ + public function on_save_options( array $post_request ): array; + + /** + * @return array + */ + public function on_load_options(): array; + } +} + +namespace JFB_Modules\Security\Exceptions { + + class Spam_Exception extends \Exception { + + /** + * @param string $message + * @param mixed ...$additional_data + */ + public function __construct( $message = '', ...$additional_data ) { + parent::__construct( (string) $message ); + } + } +} diff --git a/prosopo-procaptcha/prosopo-procaptcha.php b/prosopo-procaptcha/prosopo-procaptcha.php index 3533fbe..33899cc 100644 --- a/prosopo-procaptcha/prosopo-procaptcha.php +++ b/prosopo-procaptcha/prosopo-procaptcha.php @@ -2,7 +2,7 @@ /** * Plugin Name: Prosopo Procaptcha * Description: GDPR compliant, privacy friendly and better value captcha. - * Version: 1.20.4 + * Version: 1.21.0 * Author: Prosopo Team * Author URI: https://prosopo.io/ * License: GPLv2 or later diff --git a/prosopo-procaptcha/readme.txt b/prosopo-procaptcha/readme.txt index 8242f09..2887adb 100644 --- a/prosopo-procaptcha/readme.txt +++ b/prosopo-procaptcha/readme.txt @@ -4,7 +4,7 @@ Tags: Captcha, Procaptcha, antispam, anibot, spam. Requires at least: 5.5 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.20.4 +Stable tag: 1.21.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -46,8 +46,9 @@ Please review the [Prosopo Privacy Policy](https://prosopo.io/privacy-policy/) a 4. [Formidable Forms](https://docs.prosopo.io/en/wordpress-plugin/formidable/) - Add the `Prosopo Procaptcha` field to your form. 5. [Gravity Forms](https://docs.prosopo.io/en/wordpress-plugin/gravity-forms/) - Add the `Prosopo Procaptcha` field to your form (the `Advanced Fields` group). 6. [Ninja Forms](https://docs.prosopo.io/en/wordpress-plugin/ninja-forms/) - Add the `Prosopo Procaptcha` field to your form (the `Miscellaneous` group). -7. [User Registration](https://docs.prosopo.io/en/wordpress-plugin/user-registration/) - Add the `Prosopo Procaptcha` field to your form (the `Extra Fields` group). -8. [WP Forms](https://docs.prosopo.io/en/wordpress-plugin/wpforms/) - Add the `Prosopo Procaptcha` field to your form (the `Standard Fields` group). +7. [JetFormBuilder](https://jetformbuilder.com/) - Open the form settings and pick `Prosopo Procaptcha` as the `Captcha Provider`. +8. [User Registration](https://docs.prosopo.io/en/wordpress-plugin/user-registration/) - Add the `Prosopo Procaptcha` field to your form (the `Extra Fields` group). +9. [WP Forms](https://docs.prosopo.io/en/wordpress-plugin/wpforms/) - Add the `Prosopo Procaptcha` field to your form (the `Standard Fields` group). **WordPress Core Forms**: @@ -87,8 +88,13 @@ Account-related: 1. [Simple Membership](https://wordpress.org/plugins/simple-membership/) 2. [User Registration](https://wordpress.org/plugins/user-registration/) 3. [Memberpress](https://memberpress.com/) - Login, Reset password: via Procaptcha plugin settings -> Core forms. Membership register - via on-membership settings +4. [LearnDash LMS](https://www.learndash.com/) - Registration form: enabled automatically. Login, Reset password: via Procaptcha plugin settings -> Core forms -While only the items above are tested, overall, the Procaptcha plugin supports all the plugins with custom account forms that use the native WordPress account hooks. +Themes: +1. [Blocksy](https://creativethemes.com/blocksy/) - Account modal (Login, Register, Lost password): via Procaptcha plugin settings -> Core forms +2. [Astra](https://wpastra.com/) and [Zakra](https://zakratheme.com/) - Account forms: via Procaptcha plugin settings -> Core forms + +While only the items above are tested, overall, the Procaptcha plugin supports all the plugins and themes with custom account forms that use the native WordPress account hooks. **Other Integrations**: @@ -98,6 +104,9 @@ While only the items above are tested, overall, the Procaptcha plugin supports a 4. [WooCommerce](https://docs.prosopo.io/en/wordpress-plugin/woocommerce/) - My Account forms; Classic Checkout, Blocks Checkout, Order Tracking forms: enable protection in the `WooCommerce` tab of the plugin settings. 5. [Spectra](https://docs.prosopo.io/en/wordpress-plugin/spectra/) - Form block: add hidden input with the `prosopo_procaptcha` name 6. [Beaver Builder](https://wordpress.org/plugins/beaver-builder-lite-version/) - Contact, Subscribe forms: enabled protection in the target form settings; Login form: Procaptcha plugin settings->Core forms. +7. [Divi](https://www.elegantthemes.com/gallery/divi/) - Contact Form module: enable the built-in `Use Basic Captcha` option of the target module, and Procaptcha replaces the Divi arithmetic captcha; Login module: Procaptcha plugin settings->Core forms. +8. [Bricks](https://bricksbuilder.io/) (2.0+) - Form element: add a `Hidden` field with the `prosopo_procaptcha` label. +9. [Blocksy](https://creativethemes.com/blocksy/) - Newsletter subscribe block and shortcode: enabled automatically. **Built-In Integrations**: @@ -135,6 +144,20 @@ Absolutely! The plugin has a [public GitHub repository](https://github.com/proso == Changelog == += 1.21.0 (2026-09-01) = +* Feature: [JetFormBuilder](https://jetformbuilder.com/) integration - Procaptcha is selectable as a form's Captcha Provider +* Feature: [LearnDash LMS](https://www.learndash.com/) integration - registration, login and password recovery forms +* Feature: [Divi](https://www.elegantthemes.com/gallery/divi/) integration - Contact Form and Login modules +* Feature: [Bricks](https://bricksbuilder.io/) integration - form element +* Feature: [Blocksy](https://creativethemes.com/blocksy/) integration - newsletter subscribe form +* Maintenance: documented that the Blocksy, Astra and Zakra account forms are covered by the WordPress Core Forms integration + += 1.20.6 (2026-08-31) = +* Maintenance: simplify the statistics page to the monthly captcha counts and the traffic chart + += 1.20.5 (2026-08-31) = +* Feature: show the frictionless score ladder's image challenge threshold on the statistics page + = 1.20.4 (2026-08-10) = * Fix: fatal error on load when using Gravity Forms 3.0.2+ (get_field_label signature mismatch) diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Blocksy.php b/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Blocksy.php new file mode 100644 index 0000000..9d270ca --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Blocksy.php @@ -0,0 +1,36 @@ +name = 'Blocksy'; + $about->docs_url = self::get_docs_url( 'blocksy' ); + + return $about; + } + + public function is_active(): bool { + return defined( 'BLOCKSY_PATH' ); + } + + protected function get_hookable_integrations(): array { + return array( + new Blocksy_Newsletter( $this->widget ), + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/Blocksy_Newsletter.php b/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/Blocksy_Newsletter.php new file mode 100644 index 0000000..c0653b5 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/Blocksy_Newsletter.php @@ -0,0 +1,100 @@ +'; + const SHORTCODE = 'blocksy_newsletter_subscribe'; + + public function set_hooks( Screen_Detector $screen_detector ): void { + add_filter( 'render_block', array( $this, 'integrate_widget_into_block' ), 10, 2 ); + add_filter( 'do_shortcode_tag', array( $this, 'integrate_widget_into_shortcode' ), 10, 2 ); + + // With the low priority to be processed before the primary handler. + add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'verify_form_submission' ), -999 ); + add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION, array( $this, 'verify_form_submission' ), -999 ); + } + + /** + * @param mixed $block_content + * @param mixed $block + * + * @return mixed + */ + public function integrate_widget_into_block( $block_content, $block ) { + if ( ! is_string( $block_content ) || + self::BLOCK_NAME !== string( $block, 'blockName' ) ) { + return $block_content; + } + + return $this->add_widget_field( $block_content ); + } + + /** + * @param mixed $shortcode_content + * + * @return mixed + */ + public function integrate_widget_into_shortcode( $shortcode_content, string $shortcode_name ) { + if ( ! is_string( $shortcode_content ) || + self::SHORTCODE !== $shortcode_name ) { + return $shortcode_content; + } + + return $this->add_widget_field( $shortcode_content ); + } + + public function verify_form_submission(): void { + $widget = $this->widget; + + if ( ! $widget->is_protection_enabled() || + $widget->is_verification_token_valid() ) { + return; + } + + // Blocksy prints the 'message' key of the response, regardless of its success status. + wp_send_json_error( + array( + 'message' => $widget->get_validation_error_message(), + 'result' => 'no', + ) + ); + } + + protected function add_widget_field( string $form_content ): string { + if ( false === strpos( $form_content, self::MESSAGE_BLOCK ) ) { + return $form_content; + } + + $widget_field = $this->widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:10px 0 0', + ), + Widget_Settings::IS_RETURN_ONLY => true, + ) + ); + + return str_replace( + self::MESSAGE_BLOCK, + $widget_field . "\n" . self::MESSAGE_BLOCK, + $form_content + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/index.php b/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/index.php new file mode 100644 index 0000000..edaaa4e --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/index.php @@ -0,0 +1,2 @@ +name = 'Bricks'; + $about->docs_url = self::get_docs_url( 'bricks' ); + + return $about; + } + + public function is_active(): bool { + // Bricks is a theme, so it's not loaded yet on the 'plugins_loaded' hook, hence the template check. + return self::THEME_NAME === get_template(); + } + + protected function get_hookable_integrations(): array { + return array( + new Bricks_Form_Integration( $this->widget ), + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form.php b/prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form.php new file mode 100644 index 0000000..62e2164 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form.php @@ -0,0 +1,80 @@ +is_marker_field( $field, $marker ) ) { + continue; + } + + $field_id = string( $field, 'id' ); + + if ( '' === $field_id ) { + continue; + } + + return self::FIELD_NAME_PREFIX . $field_id; + } + + return ''; + } + + public function replace_input_in_form( string $input_name, string $replacement, string $form ): string { + $regex = sprintf( + '/(]*\bname=["\']%s["\'][^>]*>)/i', + preg_quote( $input_name, '/' ) + ); + + return (string) preg_replace( $regex, $replacement, $form, 1 ); + } + + /** + * @param mixed $submitted_fields + */ + public function get_submitted_field( $submitted_fields, string $field_name ): string { + return is_array( $submitted_fields ) ? + string( $submitted_fields, $field_name ) : + ''; + } + + /** + * @param mixed $field + */ + protected function is_marker_field( $field, string $marker ): bool { + if ( ! is_array( $field ) || + self::HIDDEN_FIELD_TYPE !== string( $field, 'type' ) ) { + return false; + } + + // The marker can be set either as the field label or as its value, whichever is available in the builder. + return string( $field, 'label' ) === $marker || + string( $field, 'value' ) === $marker; + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form_Integration.php b/prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form_Integration.php new file mode 100644 index 0000000..afe2396 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form_Integration.php @@ -0,0 +1,134 @@ +bricks_form = new Bricks_Form(); + $this->marker = $widget->get_field_name(); + } + + public function set_hooks( Screen_Detector $screen_detector ): void { + // Bricks is a theme, so its version constant isn't available yet on the 'plugins_loaded' hook. + add_action( 'after_setup_theme', array( $this, 'set_bricks_hooks' ), 20 ); + } + + public function set_bricks_hooks(): void { + if ( ! $this->is_supported_bricks_version() ) { + return; + } + + add_filter( 'bricks/frontend/render_element', array( $this, 'integrate_widget_into_form' ), 10, 2 ); + add_filter( 'bricks/form/validate', array( $this, 'verify_form_submission' ), 10, 2 ); + } + + /** + * @param mixed $element_html + * @param mixed $element + * + * @return mixed + */ + public function integrate_widget_into_form( $element_html, $element ) { + if ( ! is_string( $element_html ) || + ! is_object( $element ) || + self::FORM_ELEMENT_NAME !== string( $element, 'name' ) ) { + return $element_html; + } + + $field_name = $this->bricks_form->get_protected_field_name( arr( $element, 'settings' ), $this->marker ); + + if ( '' === $field_name ) { + return $element_html; + } + + return $this->bricks_form->replace_input_in_form( + $field_name, + $this->get_widget_field( $field_name ), + $element_html + ); + } + + /** + * @param mixed $errors + * @param mixed $form + * + * @return mixed + */ + public function verify_form_submission( $errors, $form ) { + $widget = $this->widget; + + if ( ! is_array( $errors ) || + ! is_object( $form ) || + ! method_exists( $form, 'get_settings' ) || + ! method_exists( $form, 'get_fields' ) ) { + return $errors; + } + + $field_name = $this->bricks_form->get_protected_field_name( $form->get_settings(), $this->marker ); + + if ( '' === $field_name || + ! $widget->is_protection_enabled() ) { + return $errors; + } + + $token = $this->bricks_form->get_submitted_field( $form->get_fields(), $field_name ); + + if ( $widget->is_verification_token_valid( $token ) ) { + return $errors; + } + + $errors[] = $widget->get_validation_error_message(); + + return $errors; + } + + protected function is_supported_bricks_version(): bool { + return defined( 'BRICKS_VERSION' ) && + version_compare( (string) constant( 'BRICKS_VERSION' ), self::MINIMAL_VERSION, '>=' ); + } + + protected function get_widget_field( string $field_name ): string { + return $this->widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:0 0 10px;width:100%', + ), + Widget_Settings::HIDDEN_INPUT_ATTRIBUTES => array( + 'name' => $field_name, + ), + Widget_Settings::IS_DESIRED_ON_GUESTS => true, + Widget_Settings::IS_RETURN_ONLY => true, + ) + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Bricks/index.php b/prosopo-procaptcha/src/Integrations/Plugins/Bricks/index.php new file mode 100644 index 0000000..edaaa4e --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Bricks/index.php @@ -0,0 +1,2 @@ +account_form_settings = $account_form_settings; + } + + public function get_about_integration(): About_Module_Integration { + $about = new About_Module_Integration(); + + $about->name = 'Divi'; + $about->docs_url = self::get_docs_url( 'divi' ); + + return $about; + } + + public function is_active(): bool { + // The Divi Builder plugin defines its constant before the 'plugins_loaded' hook, + // while the Divi and Extra themes are loaded later, so they're detected by the template name. + return defined( 'ET_BUILDER_PLUGIN_VERSION' ) || + in_array( get_template(), self::BUILDER_THEMES, true ); + } + + protected function get_hookable_integrations(): array { + $integrations = array( + new Divi_Contact_Form( $this->widget ), + ); + + // The Login module submits to the native WordPress login flow, so validation happens there, + // therefore that option should be active. + if ( $this->account_form_settings->is_login_protected() ) { + $integrations[] = new Divi_Login( $this->widget ); + } + + return $integrations; + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Contact_Form.php b/prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Contact_Form.php new file mode 100644 index 0000000..f8e6f3a --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Contact_Form.php @@ -0,0 +1,150 @@ +is_submission_verified || + ! $this->is_form_submitted() ) { + return $short_circuit_value; + } + + $this->is_submission_verified = $this->widget->is_verification_token_valid(); + + return $short_circuit_value; + } + + /** + * @param mixed $module_props + * @param mixed $module_attributes + * + * @return mixed + */ + public function take_over_divi_captcha( $module_props, $module_attributes, string $module_slug ) { + if ( self::MODULE_SLUG !== $module_slug || + ! is_array( $module_props ) ) { + return $module_props; + } + + $is_protected_module = self::PROP_ENABLED === ( $module_props[ self::CAPTCHA_PROP ] ?? '' ); + + $this->is_protected_module_rendering = $is_protected_module; + + if ( ! $is_protected_module ) { + return $module_props; + } + + // Keep the Divi captcha enabled for the rejected submission: its inputs are removed from the markup, + // so Divi fails the submission with its own captcha error. + $module_props[ self::CAPTCHA_PROP ] = false === $this->is_submission_verified ? + self::PROP_ENABLED : + self::PROP_DISABLED; + + return $module_props; + } + + /** + * @param mixed $module_output + * + * @return mixed + */ + public function integrate_widget_into_form( $module_output, string $module_slug ) { + if ( ! is_string( $module_output ) || + ! $this->is_protected_module_rendering || + $this->is_visual_builder() ) { + return $module_output; + } + + $this->is_protected_module_rendering = false; + + $module_output = $this->remove_divi_captcha( $module_output ); + + return $this->add_widget_field( $module_output ); + } + + protected function is_form_submitted(): bool { + // Divi checks its own nonce. + // @phpcs:ignore WordPress.Security.NonceVerification + foreach ( array_keys( $_POST ) as $argument_name ) { + if ( is_string( $argument_name ) && + 1 === preg_match( '/^et_pb_contactform_submit_\d+$/', $argument_name ) ) { + return true; + } + } + + return false; + } + + protected function is_visual_builder(): bool { + return function_exists( 'et_core_is_fb_enabled' ) && + (bool) et_core_is_fb_enabled(); + } + + protected function remove_divi_captcha( string $module_output ): string { + $divi_captcha_column = '~
[\s\S]*?
[\s\S]*?~'; + + return (string) preg_replace( $divi_captcha_column, '', $module_output ); + } + + protected function add_widget_field( string $module_output ): string { + $widget_field = $this->widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:0 0 20px;width:100%', + ), + Widget_Settings::IS_RETURN_ONLY => true, + // Divi re-renders the form with its own error, so the client-side check would be misleading. + Widget_Settings::IS_WITHOUT_CLIENT_VALIDATION => true, + ) + ); + + $submit_wrapper = '
'; + + return str_replace( + $submit_wrapper, + $widget_field . "\n" . $submit_wrapper, + $module_output + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Login.php b/prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Login.php new file mode 100644 index 0000000..5f085d2 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Login.php @@ -0,0 +1,58 @@ +is_visual_builder() ) { + return $module_output; + } + + $widget_field = $this->widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:0 0 10px;width:100%', + ), + Widget_Settings::IS_RETURN_ONLY => true, + ) + ); + + $submit_button = '/(]*>\s*get_field_name(); + } + + public function get_title(): string { + return self::get_widget()->get_field_label(); + } + + /** + * @param array $request + * + * @throws Spam_Exception When the submitted token is missing or invalid. + */ + public function verify( array $request ): void { + $widget = self::get_widget(); + + if ( ! $widget->is_protection_enabled() ) { + return; + } + + $token = string( $request, self::FIELD ); + + if ( $widget->is_verification_token_valid( $token ) ) { + return; + } + + // The message is a JetFormBuilder status slug: it maps it to the form's own 'Captcha failed' message. + // @phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + throw new Spam_Exception( Module::SPAM_EXCEPTION ); + } + + /** + * @return array + */ + public function on_load_options(): array { + // The keys are defined in the Procaptcha plugin settings, so there is nothing to load. + return array(); + } + + /** + * @param array $post_request + * + * @return array + */ + public function on_save_options( array $post_request ): array { + // The keys are defined in the Procaptcha plugin settings, so there is nothing to save. + return array(); + } + + protected function render(): string { + return self::get_widget()->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'class' => 'jet-form-builder-row', + ), + Widget_Settings::HIDDEN_INPUT_ATTRIBUTES => array( + // JetFormBuilder syncs inputs marked with 'data-jfb-sync' into its own form data store. + 'class' => self::FIELD_CLASS, + 'data-jfb-sync' => '', + 'name' => self::FIELD, + ), + Widget_Settings::IS_DESIRED_ON_GUESTS => true, + Widget_Settings::IS_RETURN_ONLY => true, + Widget_Settings::IS_WITHOUT_CLIENT_VALIDATION => true, + ) + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Captcha_Integration.php b/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Captcha_Integration.php new file mode 100644 index 0000000..dbe2090 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Captcha_Integration.php @@ -0,0 +1,31 @@ + + */ + public function add_captcha_type( $captcha_types ): array { + $captcha_types = is_array( $captcha_types ) ? + array_values( $captcha_types ) : + array(); + + $captcha_types[] = new Jet_Captcha(); + + return $captcha_types; + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Form_Builder.php b/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Form_Builder.php new file mode 100644 index 0000000..982b7b2 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Form_Builder.php @@ -0,0 +1,37 @@ +name = 'JetFormBuilder'; + $about->docs_url = self::get_docs_url( 'jetformbuilder' ); + + return $about; + } + + public function is_active(): bool { + return class_exists( 'JFB_Modules\Captcha\Abstract_Captcha\Base_Captcha' ); + } + + protected function get_external_integrations(): array { + return array( + Jet_Captcha::class, + ); + } + + protected function get_hookable_integrations(): array { + return array( + new Jet_Captcha_Integration(), + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/index.php b/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/index.php new file mode 100644 index 0000000..edaaa4e --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/index.php @@ -0,0 +1,2 @@ +is_learndash_form = true; + + return $form_args; + } + + /** + * @param mixed $content + */ + public function add_form_field( $content ): string { + $content = is_string( $content ) ? + $content : + ''; + + if ( ! $this->is_learndash_form ) { + return $content; + } + + // The args filter runs once per form, so release the mark to not affect the other forms on the page. + $this->is_learndash_form = false; + + return $content . $this->widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:0 0 10px', + ), + Widget_Settings::IS_RETURN_ONLY => true, + ) + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Password_Recovery.php b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Password_Recovery.php new file mode 100644 index 0000000..2003880 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Password_Recovery.php @@ -0,0 +1,56 @@ +widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:0 0 10px', + ), + Widget_Settings::IS_RETURN_ONLY => true, + ) + ); + + $submit_button = '/(<(?:input|button)\b[^>]*\btype=["\']submit["\'])/i'; + + return (string) preg_replace( + $submit_button, + $widget_field . "\n$1", + $shortcode_content, + 1 + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Registration.php b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Registration.php new file mode 100644 index 0000000..26f20d4 --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Registration.php @@ -0,0 +1,87 @@ +widget->print_form_field( + array( + Widget_Settings::ELEMENT_ATTRIBUTES => array( + 'style' => 'margin:0 0 10px', + ), + ) + ); + } + + public function verify_submission( WP_Error $errors ): WP_Error { + $widget = $this->widget; + + if ( ! $this->is_learndash_submission() || + $widget->is_verification_token_valid() ) { + return $errors; + } + + $this->is_submission_rejected = true; + + return $widget->get_validation_error( $errors ); + } + + /** + * @param mixed $registration_errors + * + * @return array + */ + public function add_validation_error_message( $registration_errors ): array { + $registration_errors = is_array( $registration_errors ) ? + array_map( + /** + * @param mixed $registration_error + */ + fn( $registration_error ): string => is_scalar( $registration_error ) ? + (string) $registration_error : + '', + array_values( $registration_errors ) + ) : + array(); + + $error_message = $this->widget->get_validation_error_message(); + + if ( ! $this->is_submission_rejected || + in_array( $error_message, $registration_errors, true ) ) { + return $registration_errors; + } + + $registration_errors[] = $error_message; + + return $registration_errors; + } + + protected function is_learndash_submission(): bool { + // LearnDash checks its own nonce. + return Query_Arguments::has_non_action_arg( + 'learndash-registration-form', + Query_Arguments::POST + ); + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/index.php b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/index.php new file mode 100644 index 0000000..edaaa4e --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/index.php @@ -0,0 +1,2 @@ +account_form_settings = $account_form_settings; + } + + public function get_about_integration(): About_Module_Integration { + $about = new About_Module_Integration(); + + $about->name = 'LearnDash LMS'; + $about->docs_url = self::get_docs_url( 'learndash' ); + + return $about; + } + + public function is_active(): bool { + return defined( 'LEARNDASH_VERSION' ); + } + + protected function get_hookable_integrations(): array { + $integrations = array( + new LD_Registration( $this->widget ), + ); + + // The LearnDash login and password recovery forms are processed by the native WordPress flow, + // so the validation happens there, therefore these options should be active. + if ( $this->account_form_settings->is_login_protected() ) { + $integrations[] = new LD_Login( $this->widget ); + } + + if ( $this->account_form_settings->is_password_recovery_protected() ) { + $integrations[] = new LD_Password_Recovery( $this->widget ); + } + + return $integrations; + } +} diff --git a/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/index.php b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/index.php new file mode 100644 index 0000000..edaaa4e --- /dev/null +++ b/prosopo-procaptcha/src/Integrations/Plugins/LearnDash/index.php @@ -0,0 +1,2 @@ +widget ), new Formidable_Forms( $this->widget ), new Gravity_Forms( $this->widget ), + new Jet_Form_Builder( $this->widget ), new JetPack( $this->widget ), new Ninja_Forms( $this->widget ), new Spectra( $this->widget ), @@ -247,6 +253,10 @@ protected function get_plugin_integrations(): array { new Simple_Membership( $this->widget, $this->account_form_settings ), new Beaver_Builder( $this->widget, $this->account_form_settings ), new Memberpress( $this->widget, $this->account_form_settings ), + new LearnDash( $this->widget, $this->account_form_settings ), + new Divi( $this->widget, $this->account_form_settings ), + new Bricks( $this->widget ), + new Blocksy( $this->widget ), ); } } diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index 25314cf..155b5ee 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -64,31 +64,7 @@ function ( Upgrade_Tier_Banner $model ) { return array( 'accountApiEndpoint' => Procaptcha_Plugin::ACCOUNT_API_ENDPOINT_URL, - 'accountLabels' => array( - 'name' => __( 'Name:', 'prosopo-procaptcha' ), - 'tier' => __( 'Tier:', 'prosopo-procaptcha' ), - 'title' => __( 'Account Information', 'prosopo-procaptcha' ), - ), 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, - 'captchaSettingsLabels' => array( - 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), - 'level' => array( - 'high' => __( 'High', 'prosopo-procaptcha' ), - 'low' => __( 'Low', 'prosopo-procaptcha' ), - 'normal' => __( 'Normal', 'prosopo-procaptcha' ), - ), - 'powDifficulty' => __( 'Proof of Work Difficulty:', 'prosopo-procaptcha' ), - 'title' => __( 'Captcha Settings', 'prosopo-procaptcha' ), - 'type' => __( 'Type:', 'prosopo-procaptcha' ), - 'types' => array( - 'frictionless' => __( 'Frictionless', 'prosopo-procaptcha' ), - 'image' => __( 'Image', 'prosopo-procaptcha' ), - 'proofOfWork' => __( 'Proof of Work', 'prosopo-procaptcha' ), - ), - ), - 'domainLabels' => array( - 'title' => __( 'Whitelisted Domains', 'prosopo-procaptcha' ), - ), 'isDebugMode' => false, // todo move into settings as 'debug mode' option. 'secretKey' => $secret_key, 'siteKey' => $site_key, diff --git a/prosopo-procaptcha/src/Utils/Query_Arguments.php b/prosopo-procaptcha/src/Utils/Query_Arguments.php index 6d63dfa..bc632af 100644 --- a/prosopo-procaptcha/src/Utils/Query_Arguments.php +++ b/prosopo-procaptcha/src/Utils/Query_Arguments.php @@ -15,6 +15,16 @@ final class Query_Arguments { const POST = 'post'; const SERVER = 'server'; + /** + * Tells whether the argument is present, regardless of its value. + * Some plugins mark their submissions with an empty-valued input. + */ + public static function has_non_action_arg( string $arg_name, string $from = self::GET ): bool { + $source = self::get_source( $from ); + + return key_exists( $arg_name, $source ); + } + public static function get_non_action_string( string $arg_name, string $from = self::GET ): string { $source = self::get_source( $from );