From 5de818c843b120b10d6796d8c5a6086159169c0a Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 15:32:53 +0100 Subject: [PATCH 1/9] feat(statistics): show the image challenge threshold The portal's frictionless flow gained a second score threshold: sessions past the frictionless threshold now get a puzzle, and only those at or above a higher threshold get an image captcha. The Statistics tab shows the first of those already, so it should show the second too. `frictionlessThreshold` keeps arriving as a plain number, so nothing about the existing row changes. It is now also read as the two-rung object the portal uses internally and collapsed back to its lower rung, so the tab cannot break if that shape ever reaches the plugin. The new row is blank on portals that predate the change rather than inventing a value for them. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 75 ++++++++++++++++++- .../statistics/components/appComponent.tsx | 38 ++++++++++ assets/src/settings/statistics/config.ts | 4 + .../Statistics/Statistics_Settings_Tab.php | 1 + 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index d339ea6..1cbf09e 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -2,7 +2,7 @@ import { type ProcaptchaAccount, procaptchaAccountSchema, } from "#settings/procaptcha/procaptchaAccount.js"; -import { z, type ZodType } from "zod"; +import { z, type ZodType, type ZodTypeDef } from "zod"; export interface ProcaptchaSite { account: ProcaptchaAccount; @@ -16,7 +16,18 @@ export interface ProcaptchaSite { } export interface SiteSettings { + /** + * Lower rung of the frictionless score ladder: the score a session has to + * stay under to pass without being challenged. This is the value the + * plugin has always shown as "Frictionless Threshold". + */ frictionlessThreshold: number; + /** + * Upper rung: the score at or above which a session gets an image captcha + * rather than a puzzle. Optional because portals older than the ladder + * release do not send it. + */ + frictionlessImageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -28,12 +39,68 @@ export interface CaptchaUsage { total: number; } +/** + * `SiteSettings` as it arrives on the wire, before the ladder is collapsed + * to its lower rung. Declared separately because the schema below transforms + * on parse, so its input and output types differ and `ZodType` needs both. + */ +export interface SiteSettingsInput extends Omit< + SiteSettings, + "frictionlessThreshold" +> { + frictionlessThreshold: + | number + | { + frictionlessPuzzleThreshold?: number; + frictionlessImageThreshold?: number; + }; +} + +export interface ProcaptchaSiteInput extends Omit { + settings: SiteSettingsInput; +} + +/** + * Fallback for a ladder object that arrives without its lower rung. Matches + * the portal's own default so the label the user sees does not change + * meaning between the two shapes. + */ +const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; + +/** + * The API sends `frictionlessThreshold` as a plain number, and will keep + * doing so — the plugin ships independently of the portal, so that field's + * type is part of a contract the portal cannot change from under an install + * that updates on its own schedule. + * + * It is nonetheless read here as "number, or the two-rung object", because + * internally the portal did move to the object and a future endpoint (or a + * self-hosted one) may pass it straight through. Both collapse to the puzzle + * rung, which is what this field has always meant, so the rest of the plugin + * keeps seeing a number. + */ +const frictionlessThresholdSchema = z + .union([ + z.number(), + z.object({ + frictionlessPuzzleThreshold: z.number().optional(), + frictionlessImageThreshold: z.number().optional(), + }), + ]) + .transform((value) => + "number" === typeof value + ? value + : (value.frictionlessPuzzleThreshold ?? + DEFAULT_FRICTIONLESS_THRESHOLD), + ); + export const siteSettingsSchema = z.object({ - frictionlessThreshold: z.number(), + frictionlessThreshold: frictionlessThresholdSchema, + frictionlessImageThreshold: z.number().optional(), powDifficulty: z.number(), captchaType: z.string(), domains: z.string().array(), -}) satisfies ZodType; +}) satisfies ZodType; export const captchaUsageSchema = z.object({ submissions: z.number(), @@ -50,4 +117,4 @@ export const procaptchaSiteSchema = z.object({ image: captchaUsageSchema, pow: captchaUsageSchema, }), -}) satisfies ZodType; +}) satisfies ZodType; diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index e2fc253..4e92d27 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -122,6 +122,11 @@ class AppComponent extends React.Component { .frictionlessThreshold, value: "...", }, + { + label: this.config.getCaptchaSettingsLabels() + .frictionlessImageThreshold, + value: "...", + }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, @@ -221,6 +226,32 @@ class AppComponent extends React.Component { return frictionlessThreshold < 0.4 ? levelLabels.high : levelLabels.low; } + /** + * Upper rung of the score ladder, shown as a level rather than a raw + * score to match its sibling above. A lower rung means more sessions + * reach an image captcha instead of a puzzle, so it reads as stricter. + * + * Portals older than the ladder release do not send this at all, in + * which case the row shows an em dash rather than inventing a value. + */ + protected getFrictionlessImageThresholdLabel( + frictionlessImageThreshold: number | undefined, + ): string { + if (undefined === frictionlessImageThreshold) { + return "—"; + } + + const levelLabels = this.config.getCaptchaSettingsLabels().level; + + if (frictionlessImageThreshold < 1) { + return levelLabels.high; + } + + return 1 === frictionlessImageThreshold + ? levelLabels.normal + : levelLabels.low; + } + protected getTypeLabel(type: string): string { const typeLabels = this.config.getCaptchaSettingsLabels().types; @@ -260,6 +291,13 @@ class AppComponent extends React.Component { siteSettings.frictionlessThreshold, ), }, + { + label: this.config.getCaptchaSettingsLabels() + .frictionlessImageThreshold, + value: this.getFrictionlessImageThresholdLabel( + siteSettings.frictionlessImageThreshold, + ), + }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, diff --git a/assets/src/settings/statistics/config.ts b/assets/src/settings/statistics/config.ts index 6e5d621..82c6ee1 100644 --- a/assets/src/settings/statistics/config.ts +++ b/assets/src/settings/statistics/config.ts @@ -24,6 +24,7 @@ interface CaptchaSettingsLabels { title: string; type: string; frictionlessThreshold: string; + frictionlessImageThreshold: string; powDifficulty: string; level: { low: string; @@ -151,6 +152,9 @@ class ConfigClass implements Config { frictionlessThreshold: captchaSettingsLabels.getString( "frictionlessThreshold", ), + frictionlessImageThreshold: captchaSettingsLabels.getString( + "frictionlessImageThreshold", + ), powDifficulty: captchaSettingsLabels.getString("powDifficulty"), level: { low: level.getString("low"), diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index 25314cf..7cc6460 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -71,6 +71,7 @@ function ( Upgrade_Tier_Banner $model ) { ), 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, 'captchaSettingsLabels' => array( + 'frictionlessImageThreshold' => __( 'Image Challenge Threshold:', 'prosopo-procaptcha' ), 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), 'level' => array( 'high' => __( 'High', 'prosopo-procaptcha' ), From 70dd75c2f00579383a96e376bbf5f7187d88a7ce Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 16:53:52 +0100 Subject: [PATCH 2/9] style: satisfy prettier and phpcs on the ladder changes Prettier 3.4.1 (the version pinned in yarn.lock) collapses the SiteSettingsInput extends clause onto fewer lines, and the new frictionlessImageThreshold key widened the captchaSettingsLabels array enough that phpcs wanted the whole block realigned. Co-Authored-By: Claude Opus 5 (1M context) --- assets/src/settings/procaptcha/procaptchaSite.ts | 6 ++---- .../Settings/Statistics/Statistics_Settings_Tab.php | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index 1cbf09e..b68029e 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -44,10 +44,8 @@ export interface CaptchaUsage { * to its lower rung. Declared separately because the schema below transforms * on parse, so its input and output types differ and `ZodType` needs both. */ -export interface SiteSettingsInput extends Omit< - SiteSettings, - "frictionlessThreshold" -> { +export interface SiteSettingsInput + extends Omit { frictionlessThreshold: | number | { diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index 7cc6460..b528499 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -72,16 +72,16 @@ function ( Upgrade_Tier_Banner $model ) { 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, 'captchaSettingsLabels' => array( 'frictionlessImageThreshold' => __( 'Image Challenge Threshold:', 'prosopo-procaptcha' ), - 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), - 'level' => 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( + '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' ), From 611b68faf09ab2476aef01dfd108565bddedbe15 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 18:12:39 +0100 Subject: [PATCH 3/9] fix(statistics): read the image threshold under its real wire name The portal's ClientSettingsSchema names this field imageThreshold, not frictionlessImageThreshold, so the row this feature adds was reading a key that is never present and always rendered an em dash. The level bucketing was wrong for the same reason: the score is constrained to 0..1, so the old "< 1 means High" test matched every real value and the Normal and Low labels were unreachable. Banded around the portal's 0.8 default the way the sibling threshold is banded around its 0.5 one. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 9 ++++---- .../statistics/components/appComponent.tsx | 22 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index b68029e..f52eb35 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -24,10 +24,11 @@ export interface SiteSettings { frictionlessThreshold: number; /** * Upper rung: the score at or above which a session gets an image captcha - * rather than a puzzle. Optional because portals older than the ladder - * release do not send it. + * rather than a puzzle. Named `imageThreshold` on the wire, which is the + * name the portal's own settings schema uses. Optional because portals + * older than the ladder release do not send it. */ - frictionlessImageThreshold?: number; + imageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -94,7 +95,7 @@ const frictionlessThresholdSchema = z export const siteSettingsSchema = z.object({ frictionlessThreshold: frictionlessThresholdSchema, - frictionlessImageThreshold: z.number().optional(), + imageThreshold: z.number().optional(), powDifficulty: z.number(), captchaType: z.string(), domains: z.string().array(), diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index 4e92d27..0f4a7ee 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -231,25 +231,27 @@ class AppComponent extends React.Component { * score to match its sibling above. A lower rung means more sessions * reach an image captcha instead of a puzzle, so it reads as stricter. * + * Banded around the portal's 0.8 default the same way the sibling is + * banded around its 0.5 one. The score is constrained to 0..1, so the + * bands have to sit inside that range to stay reachable. + * * Portals older than the ladder release do not send this at all, in * which case the row shows an em dash rather than inventing a value. */ - protected getFrictionlessImageThresholdLabel( - frictionlessImageThreshold: number | undefined, + protected getImageThresholdLabel( + imageThreshold: number | undefined, ): string { - if (undefined === frictionlessImageThreshold) { + if (undefined === imageThreshold) { return "—"; } const levelLabels = this.config.getCaptchaSettingsLabels().level; - if (frictionlessImageThreshold < 1) { - return levelLabels.high; + if (imageThreshold >= 0.7 && imageThreshold <= 0.9) { + return levelLabels.normal; } - return 1 === frictionlessImageThreshold - ? levelLabels.normal - : levelLabels.low; + return imageThreshold < 0.7 ? levelLabels.high : levelLabels.low; } protected getTypeLabel(type: string): string { @@ -294,8 +296,8 @@ class AppComponent extends React.Component { { label: this.config.getCaptchaSettingsLabels() .frictionlessImageThreshold, - value: this.getFrictionlessImageThresholdLabel( - siteSettings.frictionlessImageThreshold, + value: this.getImageThresholdLabel( + siteSettings.imageThreshold, ), }, { From d22bc0d0dc8cab6d55a2b88cf368514fd8b83018 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 20:53:55 +0100 Subject: [PATCH 4/9] fix(statistics): read the image rung from inside the ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upper rung arrives nested inside frictionlessThreshold, not as a sibling of it. The row was reading a top-level field that is never sent, so it always rendered an em dash. An earlier attempt pointed it at the settings' own imageThreshold, which does exist on the wire but is an unrelated image-captcha setting on a 0..1 scale — not the ladder rung the row is labelled for. Reverted. The union that tolerates both wire shapes is load-bearing rather than defensive: the portal has moved frictionlessThreshold to the ladder, but records migrate in the background and the WordPress endpoint still emits the bare number today, so an install can meet either. The transform now splits the ladder into two flat fields instead of collapsing it and discarding the image rung. Banding restored to the 1.0 default: unlike the lower rung this one is deliberately allowed above 1, because the score compared against it is a total that server-side penalties add to. The 0.7-0.9 band from the earlier attempt belonged to imageThreshold's 0..1 scale. Verified against the live endpoint: settings.frictionlessThreshold is still 0.5, so the row shows an em dash until the portal API ships the ladder, then fills in with no further plugin change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 89 +++++++++++-------- .../statistics/components/appComponent.tsx | 29 +++--- 2 files changed, 66 insertions(+), 52 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index f52eb35..f7982a0 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -24,11 +24,14 @@ export interface SiteSettings { frictionlessThreshold: number; /** * Upper rung: the score at or above which a session gets an image captcha - * rather than a puzzle. Named `imageThreshold` on the wire, which is the - * name the portal's own settings schema uses. Optional because portals - * older than the ladder release do not send it. + * rather than a puzzle. Arrives nested inside `frictionlessThreshold`, not + * as a sibling of it, and is absent whenever that field is still the bare + * number the pre-ladder API sends. + * + * Not to be confused with the settings' own `imageThreshold`, which is an + * unrelated image-captcha setting on a 0..1 scale. */ - imageThreshold?: number; + frictionlessImageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -41,12 +44,16 @@ export interface CaptchaUsage { } /** - * `SiteSettings` as it arrives on the wire, before the ladder is collapsed - * to its lower rung. Declared separately because the schema below transforms - * on parse, so its input and output types differ and `ZodType` needs both. + * `SiteSettings` as it arrives on the wire, before the ladder is split into + * the two flat fields the rest of the plugin reads. Declared separately + * because the schema below transforms on parse, so its input and output + * types differ and `ZodType` needs both. */ export interface SiteSettingsInput - extends Omit { + extends Omit< + SiteSettings, + "frictionlessThreshold" | "frictionlessImageThreshold" + > { frictionlessThreshold: | number | { @@ -67,39 +74,43 @@ export interface ProcaptchaSiteInput extends Omit { const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; /** - * The API sends `frictionlessThreshold` as a plain number, and will keep - * doing so — the plugin ships independently of the portal, so that field's - * type is part of a contract the portal cannot change from under an install - * that updates on its own schedule. - * - * It is nonetheless read here as "number, or the two-rung object", because - * internally the portal did move to the object and a future endpoint (or a - * self-hosted one) may pass it straight through. Both collapse to the puzzle - * rung, which is what this field has always meant, so the rest of the plugin - * keeps seeing a number. + * `frictionlessThreshold` is read as "number, or the two-rung ladder object" + * because both are live at once: the portal moved the field to the ladder, + * but records migrate in the background and the plugin ships independently + * of the portal, so an install can be talking to either shape. A bare number + * means what it always meant — the puzzle rung — and carries no image rung. */ -const frictionlessThresholdSchema = z - .union([ - z.number(), - z.object({ - frictionlessPuzzleThreshold: z.number().optional(), - frictionlessImageThreshold: z.number().optional(), - }), - ]) - .transform((value) => - "number" === typeof value - ? value - : (value.frictionlessPuzzleThreshold ?? - DEFAULT_FRICTIONLESS_THRESHOLD), - ); +const frictionlessThresholdSchema = z.union([ + z.number(), + z.object({ + frictionlessPuzzleThreshold: z.number().optional(), + frictionlessImageThreshold: z.number().optional(), + }), +]); -export const siteSettingsSchema = z.object({ - frictionlessThreshold: frictionlessThresholdSchema, - imageThreshold: z.number().optional(), - powDifficulty: z.number(), - captchaType: z.string(), - domains: z.string().array(), -}) satisfies ZodType; +/** + * Split the ladder into the two flat fields the rest of the plugin reads, so + * nothing downstream has to know which of the two wire shapes arrived. + */ +export const siteSettingsSchema = z + .object({ + frictionlessThreshold: frictionlessThresholdSchema, + powDifficulty: z.number(), + captchaType: z.string(), + domains: z.string().array(), + }) + .transform(({ frictionlessThreshold, ...settings }) => ({ + ...settings, + frictionlessThreshold: + "number" === typeof frictionlessThreshold + ? frictionlessThreshold + : (frictionlessThreshold.frictionlessPuzzleThreshold ?? + DEFAULT_FRICTIONLESS_THRESHOLD), + frictionlessImageThreshold: + "number" === typeof frictionlessThreshold + ? undefined + : frictionlessThreshold.frictionlessImageThreshold, + })) satisfies ZodType; export const captchaUsageSchema = z.object({ submissions: z.number(), diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index 0f4a7ee..9f25eca 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -231,27 +231,30 @@ class AppComponent extends React.Component { * score to match its sibling above. A lower rung means more sessions * reach an image captcha instead of a puzzle, so it reads as stricter. * - * Banded around the portal's 0.8 default the same way the sibling is - * banded around its 0.5 one. The score is constrained to 0..1, so the - * bands have to sit inside that range to stay reachable. + * Banded on the portal's 1.0 default rather than a range: unlike the + * lower rung this one is deliberately allowed above 1, because the score + * it is compared against is a total that server-side penalties add to. * - * Portals older than the ladder release do not send this at all, in - * which case the row shows an em dash rather than inventing a value. + * An API still sending the pre-ladder bare `frictionlessThreshold` gives + * no upper rung at all, in which case the row shows an em dash rather + * than inventing a value. */ - protected getImageThresholdLabel( - imageThreshold: number | undefined, + protected getFrictionlessImageThresholdLabel( + frictionlessImageThreshold: number | undefined, ): string { - if (undefined === imageThreshold) { + if (undefined === frictionlessImageThreshold) { return "—"; } const levelLabels = this.config.getCaptchaSettingsLabels().level; - if (imageThreshold >= 0.7 && imageThreshold <= 0.9) { - return levelLabels.normal; + if (frictionlessImageThreshold < 1) { + return levelLabels.high; } - return imageThreshold < 0.7 ? levelLabels.high : levelLabels.low; + return 1 === frictionlessImageThreshold + ? levelLabels.normal + : levelLabels.low; } protected getTypeLabel(type: string): string { @@ -296,8 +299,8 @@ class AppComponent extends React.Component { { label: this.config.getCaptchaSettingsLabels() .frictionlessImageThreshold, - value: this.getImageThresholdLabel( - siteSettings.imageThreshold, + value: this.getFrictionlessImageThresholdLabel( + siteSettings.frictionlessImageThreshold, ), }, { From 82b9aa2f9b36034c505d364cd516157f7380dc74 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 31 Aug 2026 11:14:01 +0100 Subject: [PATCH 5/9] Bump version to 1.20.5 Co-Authored-By: Claude Opus 5 (1M context) --- prosopo-procaptcha/prosopo-procaptcha.php | 2 +- prosopo-procaptcha/readme.txt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/prosopo-procaptcha/prosopo-procaptcha.php b/prosopo-procaptcha/prosopo-procaptcha.php index 3533fbe..f2b1601 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.20.5 * 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..d434ff1 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.20.5 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -135,6 +135,9 @@ Absolutely! The plugin has a [public GitHub repository](https://github.com/proso == Changelog == += 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) From cef066c4c55808380ce1a5b126e335b74d608dd7 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 31 Aug 2026 11:30:20 +0100 Subject: [PATCH 6/9] Remove the image challenge threshold from the statistics page The row was added in 1.20.5 but renders an em dash for every install: the value lives on the upper rung of the frictionless ladder, and the WordPress endpoint still sends frictionlessThreshold as a bare number, so there is nothing to show and no date by which there will be. Reverts the row, its label and its level helper. The statistics page is byte-identical to 1.20.4 again. The schema union is kept deliberately. It is not part of displaying the value: the portal has moved frictionlessThreshold to the ladder object, and because the whole site response is a single parse, a ladder arriving at a bare z.number() would fail and take out the entire statistics tab rather than one row. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 81 +++++++------------ .../statistics/components/appComponent.tsx | 43 ---------- assets/src/settings/statistics/config.ts | 4 - prosopo-procaptcha/prosopo-procaptcha.php | 2 +- prosopo-procaptcha/readme.txt | 5 +- .../Statistics/Statistics_Settings_Tab.php | 13 ++- 6 files changed, 42 insertions(+), 106 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index f7982a0..43659a2 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -22,16 +22,6 @@ export interface SiteSettings { * plugin has always shown as "Frictionless Threshold". */ frictionlessThreshold: number; - /** - * Upper rung: the score at or above which a session gets an image captcha - * rather than a puzzle. Arrives nested inside `frictionlessThreshold`, not - * as a sibling of it, and is absent whenever that field is still the bare - * number the pre-ladder API sends. - * - * Not to be confused with the settings' own `imageThreshold`, which is an - * unrelated image-captcha setting on a 0..1 scale. - */ - frictionlessImageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -44,16 +34,13 @@ export interface CaptchaUsage { } /** - * `SiteSettings` as it arrives on the wire, before the ladder is split into - * the two flat fields the rest of the plugin reads. Declared separately - * because the schema below transforms on parse, so its input and output - * types differ and `ZodType` needs both. + * `SiteSettings` as it arrives on the wire, before the ladder is collapsed to + * the single number the rest of the plugin reads. Declared separately because + * the schema below transforms on parse, so its input and output types differ + * and `ZodType` needs both. */ export interface SiteSettingsInput - extends Omit< - SiteSettings, - "frictionlessThreshold" | "frictionlessImageThreshold" - > { + extends Omit { frictionlessThreshold: | number | { @@ -77,40 +64,34 @@ const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; * `frictionlessThreshold` is read as "number, or the two-rung ladder object" * because both are live at once: the portal moved the field to the ladder, * but records migrate in the background and the plugin ships independently - * of the portal, so an install can be talking to either shape. A bare number - * means what it always meant — the puzzle rung — and carries no image rung. + * of the portal, so an install can be talking to either shape. + * + * The plugin only displays the puzzle rung, so the ladder collapses to it and + * the image rung is ignored. The union still has to be here: without it a + * ladder object fails `z.number()`, and because the whole site response is one + * parse, that failure takes out the entire statistics tab rather than one row. */ -const frictionlessThresholdSchema = z.union([ - z.number(), - z.object({ - frictionlessPuzzleThreshold: z.number().optional(), - frictionlessImageThreshold: z.number().optional(), - }), -]); +const frictionlessThresholdSchema = z + .union([ + z.number(), + z.object({ + frictionlessPuzzleThreshold: z.number().optional(), + frictionlessImageThreshold: z.number().optional(), + }), + ]) + .transform((value) => + "number" === typeof value + ? value + : (value.frictionlessPuzzleThreshold ?? + DEFAULT_FRICTIONLESS_THRESHOLD), + ); -/** - * Split the ladder into the two flat fields the rest of the plugin reads, so - * nothing downstream has to know which of the two wire shapes arrived. - */ -export const siteSettingsSchema = z - .object({ - frictionlessThreshold: frictionlessThresholdSchema, - powDifficulty: z.number(), - captchaType: z.string(), - domains: z.string().array(), - }) - .transform(({ frictionlessThreshold, ...settings }) => ({ - ...settings, - frictionlessThreshold: - "number" === typeof frictionlessThreshold - ? frictionlessThreshold - : (frictionlessThreshold.frictionlessPuzzleThreshold ?? - DEFAULT_FRICTIONLESS_THRESHOLD), - frictionlessImageThreshold: - "number" === typeof frictionlessThreshold - ? undefined - : frictionlessThreshold.frictionlessImageThreshold, - })) satisfies ZodType; +export const siteSettingsSchema = z.object({ + frictionlessThreshold: frictionlessThresholdSchema, + powDifficulty: z.number(), + captchaType: z.string(), + domains: z.string().array(), +}) satisfies ZodType; export const captchaUsageSchema = z.object({ submissions: z.number(), diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index 9f25eca..e2fc253 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -122,11 +122,6 @@ class AppComponent extends React.Component { .frictionlessThreshold, value: "...", }, - { - label: this.config.getCaptchaSettingsLabels() - .frictionlessImageThreshold, - value: "...", - }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, @@ -226,37 +221,6 @@ class AppComponent extends React.Component { return frictionlessThreshold < 0.4 ? levelLabels.high : levelLabels.low; } - /** - * Upper rung of the score ladder, shown as a level rather than a raw - * score to match its sibling above. A lower rung means more sessions - * reach an image captcha instead of a puzzle, so it reads as stricter. - * - * Banded on the portal's 1.0 default rather than a range: unlike the - * lower rung this one is deliberately allowed above 1, because the score - * it is compared against is a total that server-side penalties add to. - * - * An API still sending the pre-ladder bare `frictionlessThreshold` gives - * no upper rung at all, in which case the row shows an em dash rather - * than inventing a value. - */ - protected getFrictionlessImageThresholdLabel( - frictionlessImageThreshold: number | undefined, - ): string { - if (undefined === frictionlessImageThreshold) { - return "—"; - } - - const levelLabels = this.config.getCaptchaSettingsLabels().level; - - if (frictionlessImageThreshold < 1) { - return levelLabels.high; - } - - return 1 === frictionlessImageThreshold - ? levelLabels.normal - : levelLabels.low; - } - protected getTypeLabel(type: string): string { const typeLabels = this.config.getCaptchaSettingsLabels().types; @@ -296,13 +260,6 @@ class AppComponent extends React.Component { siteSettings.frictionlessThreshold, ), }, - { - label: this.config.getCaptchaSettingsLabels() - .frictionlessImageThreshold, - value: this.getFrictionlessImageThresholdLabel( - siteSettings.frictionlessImageThreshold, - ), - }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, diff --git a/assets/src/settings/statistics/config.ts b/assets/src/settings/statistics/config.ts index 82c6ee1..6e5d621 100644 --- a/assets/src/settings/statistics/config.ts +++ b/assets/src/settings/statistics/config.ts @@ -24,7 +24,6 @@ interface CaptchaSettingsLabels { title: string; type: string; frictionlessThreshold: string; - frictionlessImageThreshold: string; powDifficulty: string; level: { low: string; @@ -152,9 +151,6 @@ class ConfigClass implements Config { frictionlessThreshold: captchaSettingsLabels.getString( "frictionlessThreshold", ), - frictionlessImageThreshold: captchaSettingsLabels.getString( - "frictionlessImageThreshold", - ), powDifficulty: captchaSettingsLabels.getString("powDifficulty"), level: { low: level.getString("low"), diff --git a/prosopo-procaptcha/prosopo-procaptcha.php b/prosopo-procaptcha/prosopo-procaptcha.php index f2b1601..813d411 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.5 + * Version: 1.20.6 * 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 d434ff1..107babd 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.5 +Stable tag: 1.20.6 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -135,6 +135,9 @@ Absolutely! The plugin has a [public GitHub repository](https://github.com/proso == Changelog == += 1.20.6 (2026-08-31) = +* Maintenance: remove the image challenge threshold from the statistics page + = 1.20.5 (2026-08-31) = * Feature: show the frictionless score ladder's image challenge threshold on the statistics page diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index b528499..25314cf 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -71,17 +71,16 @@ function ( Upgrade_Tier_Banner $model ) { ), 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, 'captchaSettingsLabels' => array( - 'frictionlessImageThreshold' => __( 'Image Challenge Threshold:', 'prosopo-procaptcha' ), - 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), - 'level' => 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( + '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' ), From f29e65ead34573b905910990683129bcab803202 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 31 Aug 2026 11:37:55 +0100 Subject: [PATCH 7/9] Reduce the statistics page to captcha counts and the traffic chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the Account Information, Captcha Settings and Whitelisted Domains panels, leaving the monthly image/PoW counts and the traffic chart. Takes the throw risk out at the root rather than guarding against it. `settings` is no longer declared on the site schema at all, so zod strips it: the whole response is a single parse, and a field the page does not display has no business being able to fail it. That removes the frictionlessThreshold union, its transform, and the Input types that existed only to describe them. Also removes the now-orphaned ListComponent and the account, captcha settings and domain label sets on both the TS and PHP sides. Note: no puzzle count. The endpoint does not carry one — MonthlyCaptchaRequestsSchema is limit/image/pow/year/month — so showing it needs a portal change first. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 90 ++-------- .../statistics/components/appComponent.tsx | 167 +----------------- .../statistics/components/listComponent.tsx | 38 ---- assets/src/settings/statistics/config.ts | 88 +-------- prosopo-procaptcha/readme.txt | 2 +- .../Statistics/Statistics_Settings_Tab.php | 24 --- 6 files changed, 18 insertions(+), 391 deletions(-) delete mode 100644 assets/src/settings/statistics/components/listComponent.tsx diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index 43659a2..e322aa2 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -2,12 +2,20 @@ import { type ProcaptchaAccount, procaptchaAccountSchema, } from "#settings/procaptcha/procaptchaAccount.js"; -import { z, type ZodType, type ZodTypeDef } from "zod"; +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,84 +23,12 @@ export interface ProcaptchaSite { }; } -export interface SiteSettings { - /** - * Lower rung of the frictionless score ladder: the score a session has to - * stay under to pass without being challenged. This is the value the - * plugin has always shown as "Frictionless Threshold". - */ - frictionlessThreshold: number; - powDifficulty: number; - captchaType: string; - domains: string[]; -} - export interface CaptchaUsage { submissions: number; verifications: number; total: number; } -/** - * `SiteSettings` as it arrives on the wire, before the ladder is collapsed to - * the single number the rest of the plugin reads. Declared separately because - * the schema below transforms on parse, so its input and output types differ - * and `ZodType` needs both. - */ -export interface SiteSettingsInput - extends Omit { - frictionlessThreshold: - | number - | { - frictionlessPuzzleThreshold?: number; - frictionlessImageThreshold?: number; - }; -} - -export interface ProcaptchaSiteInput extends Omit { - settings: SiteSettingsInput; -} - -/** - * Fallback for a ladder object that arrives without its lower rung. Matches - * the portal's own default so the label the user sees does not change - * meaning between the two shapes. - */ -const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; - -/** - * `frictionlessThreshold` is read as "number, or the two-rung ladder object" - * because both are live at once: the portal moved the field to the ladder, - * but records migrate in the background and the plugin ships independently - * of the portal, so an install can be talking to either shape. - * - * The plugin only displays the puzzle rung, so the ladder collapses to it and - * the image rung is ignored. The union still has to be here: without it a - * ladder object fails `z.number()`, and because the whole site response is one - * parse, that failure takes out the entire statistics tab rather than one row. - */ -const frictionlessThresholdSchema = z - .union([ - z.number(), - z.object({ - frictionlessPuzzleThreshold: z.number().optional(), - frictionlessImageThreshold: z.number().optional(), - }), - ]) - .transform((value) => - "number" === typeof value - ? value - : (value.frictionlessPuzzleThreshold ?? - DEFAULT_FRICTIONLESS_THRESHOLD), - ); - -export const siteSettingsSchema = z.object({ - frictionlessThreshold: frictionlessThresholdSchema, - powDifficulty: z.number(), - captchaType: z.string(), - domains: z.string().array(), -}) satisfies ZodType; - export const captchaUsageSchema = z.object({ submissions: z.number(), verifications: z.number(), @@ -101,11 +37,9 @@ 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, pow: captchaUsageSchema, }), -}) satisfies ZodType; +}) satisfies ZodType; 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/prosopo-procaptcha/readme.txt b/prosopo-procaptcha/readme.txt index 107babd..1393d51 100644 --- a/prosopo-procaptcha/readme.txt +++ b/prosopo-procaptcha/readme.txt @@ -136,7 +136,7 @@ Absolutely! The plugin has a [public GitHub repository](https://github.com/proso == Changelog == = 1.20.6 (2026-08-31) = -* Maintenance: remove the image challenge threshold from the statistics page +* 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 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, From 15c5a4fd1b9701b3d9f50aef897966d68a3b7faa Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 1 Sep 2026 20:59:35 +0100 Subject: [PATCH 8/9] feat: JetFormBuilder, LearnDash, Divi, Bricks and Blocksy integrations Adds five plugin/theme integrations that agencies asked for: - JetFormBuilder: registers Procaptcha through the native 'jet-form-builder/captcha/types' filter, so it shows up in the form's "Captcha Provider" dropdown and is verified by JetFormBuilder's own request handler (no editor JS needed). - LearnDash LMS: registration form (rendered on 'learndash_registration_form', verified on 'registration_errors'), plus login and password recovery, which submit through the native WordPress flow and so reuse the Core Forms validation. - Divi: Contact Form module (the built-in "Use Basic Captcha" toggle is the opt-in; a rejected submission keeps Divi's own captcha enabled so Divi fails the submission itself) and the Login module. - Bricks 2.0+: a Hidden field labelled 'prosopo_procaptcha' marks the form; the widget replaces its input, so the token arrives as a regular Bricks field, and 'bricks/form/validate' verifies it. Rendering and validation resolve the field from the same settings shape, so they can't diverge. - Blocksy: newsletter subscribe block and shortcode. The account modal forms already work through the Core Forms integration - documented. Tests: the CI mu-plugin now pins the reserved Prosopo test site key (https://docs.prosopo.io/en/basics/test-keys/) instead of relying on the keys carried in the private database dump, so the suite no longer depends on a personal Prosopo account. Co-Authored-By: Claude Opus 5 (1M context) --- data-for-tests/mu-plugin.php | 27 ++++ .../stubs/Jet_Form_Builder_Captcha.php | 93 +++++++++++ prosopo-procaptcha/prosopo-procaptcha.php | 2 +- prosopo-procaptcha/readme.txt | 25 ++- .../Integrations/Plugins/Blocksy/Blocksy.php | 36 +++++ .../Blocksy/Forms/Blocksy_Newsletter.php | 100 ++++++++++++ .../Plugins/Blocksy/Forms/index.php | 2 + .../Integrations/Plugins/Blocksy/index.php | 2 + .../Integrations/Plugins/Bricks/Bricks.php | 34 ++++ .../Plugins/Bricks/Bricks_Form.php | 80 ++++++++++ .../Bricks/Bricks_Form_Integration.php | 134 ++++++++++++++++ .../src/Integrations/Plugins/Bricks/index.php | 2 + .../src/Integrations/Plugins/Divi/Divi.php | 59 +++++++ .../Plugins/Divi/Forms/Divi_Contact_Form.php | 150 ++++++++++++++++++ .../Plugins/Divi/Forms/Divi_Login.php | 58 +++++++ .../Integrations/Plugins/Divi/Forms/index.php | 2 + .../src/Integrations/Plugins/Divi/index.php | 2 + .../Plugins/Jet_Form_Builder/Jet_Captcha.php | 95 +++++++++++ .../Jet_Captcha_Integration.php | 31 ++++ .../Jet_Form_Builder/Jet_Form_Builder.php | 37 +++++ .../Plugins/Jet_Form_Builder/index.php | 2 + .../Plugins/LearnDash/Forms/LD_Login.php | 62 ++++++++ .../LearnDash/Forms/LD_Password_Recovery.php | 56 +++++++ .../LearnDash/Forms/LD_Registration.php | 87 ++++++++++ .../Plugins/LearnDash/Forms/index.php | 2 + .../Plugins/LearnDash/LearnDash.php | 56 +++++++ .../Integrations/Plugins/LearnDash/index.php | 2 + prosopo-procaptcha/src/Procaptcha_Plugin.php | 10 ++ .../src/Utils/Query_Arguments.php | 10 ++ 29 files changed, 1253 insertions(+), 5 deletions(-) create mode 100644 php-tools/code-quality/stubs/Jet_Form_Builder_Captcha.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Blocksy.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/Blocksy_Newsletter.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Blocksy/Forms/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Blocksy/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Bricks/Bricks_Form_Integration.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Bricks/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Divi/Divi.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Contact_Form.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/Divi_Login.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Divi/Forms/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Divi/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Captcha.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Captcha_Integration.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/Jet_Form_Builder.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/Jet_Form_Builder/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Login.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Password_Recovery.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/LD_Registration.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/LearnDash/Forms/index.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/LearnDash/LearnDash.php create mode 100644 prosopo-procaptcha/src/Integrations/Plugins/LearnDash/index.php diff --git a/data-for-tests/mu-plugin.php b/data-for-tests/mu-plugin.php index 5e13c95..46c16cd 100644 --- a/data-for-tests/mu-plugin.php +++ b/data-for-tests/mu-plugin.php @@ -2,6 +2,33 @@ define( 'PROSOPO_PROCAPTCHA_ALLOW_BYPASS', true ); +/** + * Reserved Prosopo test site keys, see https://docs.prosopo.io/en/basics/test-keys/ + * + * They're 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-pass key is used, as the always-fail one emits no token at all, + * while the suite needs to submit both valid and invalid tokens. + * 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', '5EARALUe4HXQwKo5KanZSGGKqJV4VTaytpezFwv8ZHbZewmh' ); +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 813d411..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.6 + * 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 1393d51..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.6 +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,14 @@ 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 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/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 ); From 1e9cc095c9ce0b358485d97fb5826775b60a3530 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 1 Sep 2026 21:02:01 +0100 Subject: [PATCH 9/9] test: use the always-fail reserved key so the widget can't overwrite the suite's tokens Co-Authored-By: Claude Opus 5 (1M context) --- data-for-tests/mu-plugin.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/data-for-tests/mu-plugin.php b/data-for-tests/mu-plugin.php index 46c16cd..ecc3b2a 100644 --- a/data-for-tests/mu-plugin.php +++ b/data-for-tests/mu-plugin.php @@ -3,17 +3,19 @@ define( 'PROSOPO_PROCAPTCHA_ALLOW_BYPASS', true ); /** - * Reserved Prosopo test site keys, see https://docs.prosopo.io/en/basics/test-keys/ + * Reserved Prosopo test site key, see https://docs.prosopo.io/en/basics/test-keys/ * - * They're constant across all the Prosopo environments, require no registration, + * 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-pass key is used, as the always-fail one emits no token at all, - * while the suite needs to submit both valid and invalid tokens. + * 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', '5EARALUe4HXQwKo5KanZSGGKqJV4VTaytpezFwv8ZHbZewmh' ); +define( 'PROSOPO_PROCAPTCHA_TEST_SITE_KEY', '5ETtechmZkn3CUVeJX7Z511oiuiu742aHLm91D5ZZw4fqoAG' ); define( 'PROSOPO_PROCAPTCHA_TEST_SECRET_KEY', 'test-secret-key-is-ignored-for-reserved-site-keys' ); add_filter(