Add Shield silentCAPTCHA integration#58
Add Shield silentCAPTCHA integration#58paulgoodchild wants to merge 3 commits intoGravityKit:developfrom
Conversation
Wire a dedicated Shield integration into the Zero Spam add-on and route Shield verdicts through Gravity Forms spam handling, save-and-continue aborts, and fallback entry notes. Add plugin and per-form settings with normalized hidden persist fields so the extra UI state can be saved without tripping Gravity Forms validation. Treat the plugin setting as the default and let forms inherit it until they save an explicit override. Make the admin UI state-aware by disabling controls when Shield is unavailable, surfacing threshold-zero guidance in the right context, and keeping the per-form tooltip and inline help aligned with whether the form is inheriting or overriding the global default.
WalkthroughAdds a Shield SilentCAPTCHA integration component to the GF Zero Spam add-on: initializes the component, extends plugin and per-form settings handling and normalization, and uses Shield verdicts to influence spam detection, optional draft-abort, and fallback entry-note creation. Changes
Sequence DiagramsequenceDiagram
actor User as User
participant GF as Gravity Forms
participant ZeroSpam as GF Zero Spam AddOn
participant Shield as Shield SilentCAPTCHA
participant DB as Entry DB
User->>GF: Submit form
GF->>ZeroSpam: gform_entry_is_spam filter
ZeroSpam->>ZeroSpam: Check preconditions (already spam, privileged user, preview, form toggle)
alt Precondition blocks
ZeroSpam-->>GF: return existing spam status
else
ZeroSpam->>Shield: Request verdict
Shield-->>ZeroSpam: Return verdict (true/false/null)
alt verdict === true
ZeroSpam->>ZeroSpam: Mark entry as spam (GFCommon if available)
ZeroSpam-->>GF: return true
alt Save-and-Continue draft and not editing
ZeroSpam->>GF: abort draft creation
end
else
ZeroSpam-->>GF: return false
end
end
GF->>DB: Save entry
alt fallback tracked form and GFCommon unavailable
ZeroSpam->>DB: Add entry note marking spam verdict
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@includes/class-gf-zero-spam-shield-silent-captcha.php`:
- Around line 141-149: The current normalize_form_settings implementation always
writes back a concrete value for self::SETTING_KEY, which materializes
inherited/global defaults and prevents future global changes from propagating;
change the logic in normalize_form_settings (and the similar block around the
350-366 region) so you only set $form[self::SETTING_KEY] when the submitted or
persisted input explicitly contains the key (rgpost(self::SETTING_KEY) or
rgpost(self::PERSIST_KEY)), otherwise remove/leave the key absent to preserve
the “inherit/missing” state—use resolve_setting_value_for_save and
get_effective_form_setting_value to compute values for validation but avoid
saving that computed value back into the form when neither submitted nor
persisted values were provided.
- Around line 294-304: render_form_status_field currently only outputs the
inline help/description when is_threshold_zero() is true (and emits only JS when
is_shield_available() is false), which hides the per-form status/help row in
normal and unavailable cases; change the logic so the description HTML from
get_form_tooltip_text( $this->addon->get_current_form() ) is always rendered
(assign a $help_html using esc_html and the same <p class="description"> markup)
and then append/merge the existing render_disable_control_script() output when
is_shield_available() is false (or keep the threshold-zero branch behavior for
any extra messaging), ensuring you still call is_threshold_zero(),
is_shield_available(), render_disable_control_script(), and
get_form_tooltip_text() as reference points when restructuring the conditionals
so the help row is always visible while retaining the JS disabled controls.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5aa6a20d-851c-4785-af06-952061522847
📒 Files selected for processing (2)
includes/class-gf-zero-spam-addon.phpincludes/class-gf-zero-spam-shield-silent-captcha.php
| public function render_form_status_field( $field, $echo = true ): ?string { | ||
| $html = ''; | ||
|
|
||
| if ( ! $this->is_shield_available() ) { | ||
| $html = $this->render_disable_control_script( [ '_gform_setting_' . self::SETTING_KEY, self::SETTING_KEY ] ); | ||
| } elseif ( $this->is_threshold_zero() ) { | ||
| $html = sprintf( | ||
| '<p class="description" style="color:#996800;">%s</p>', | ||
| esc_html( $this->get_form_tooltip_text( $this->addon->get_current_form() ) ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Keep the per-form status/help row visible outside the threshold-zero case.
Line 368 hides the HTML field unless the threshold is zero, so normal forms never show the inherit/override help text. When Shield is unavailable, render_form_status_field() only emits JS, so there is still no inline status/help message for that state either.
Possible fix to always render the inline help
public function render_form_status_field( $field, $echo = true ): ?string {
- $html = '';
-
- if ( ! $this->is_shield_available() ) {
- $html = $this->render_disable_control_script( [ '_gform_setting_' . self::SETTING_KEY, self::SETTING_KEY ] );
- } elseif ( $this->is_threshold_zero() ) {
- $html = sprintf(
- '<p class="description" style="color:`#996800`;">%s</p>',
- esc_html( $this->get_form_tooltip_text( $this->addon->get_current_form() ) )
- );
- }
+ $style = $this->is_threshold_zero() ? ' style="color:`#996800`;"' : '';
+ $html = sprintf(
+ '<p class="description"%s>%s</p>',
+ $style,
+ esc_html( $this->get_form_tooltip_text( $this->addon->get_current_form() ) )
+ );
+
+ if ( ! $this->is_shield_available() ) {
+ $html .= $this->render_disable_control_script( [ '_gform_setting_' . self::SETTING_KEY, self::SETTING_KEY ] );
+ }
if ( $echo ) {
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is escaped at source.
@@
[
- 'hidden' => ! $this->is_threshold_zero(),
'type' => 'html',
'name' => self::STATUS_FIELD_KEY,
'label' => '',
'callback' => [ $this, 'render_form_status_field' ],
],Also applies to: 368-373
🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 294-294: Avoid unused parameters such as '$field'. (undefined)
(UnusedFormalParameter)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@includes/class-gf-zero-spam-shield-silent-captcha.php` around lines 294 -
304, render_form_status_field currently only outputs the inline help/description
when is_threshold_zero() is true (and emits only JS when is_shield_available()
is false), which hides the per-form status/help row in normal and unavailable
cases; change the logic so the description HTML from get_form_tooltip_text(
$this->addon->get_current_form() ) is always rendered (assign a $help_html using
esc_html and the same <p class="description"> markup) and then append/merge the
existing render_disable_control_script() output when is_shield_available() is
false (or keep the threshold-zero branch behavior for any extra messaging),
ensuring you still call is_threshold_zero(), is_shield_available(),
render_disable_control_script(), and get_form_tooltip_text() as reference points
when restructuring the conditionals so the help row is always visible while
retaining the JS disabled controls.
There was a problem hiding this comment.
This is intentional - we don't pollute the UI in this section with additional HTML/helper text to keep it aligned with the other options, and because we take advantage of the tooltips that are available. This display is a special case where the info becomes unhidden so that users don't mistakenly think that because the silentCAPTCHA is switched-on that it's working. This warning alerts them to take action they would otherwise not be aware of.
There was a problem hiding this comment.
@paulgoodchild, thanks for the clear explanation! That makes perfect sense — keeping the UI clean by relying on tooltips for standard help text, and reserving the visible inline paragraph exclusively as an actionable warning when the threshold-zero state would otherwise make it appear that Shield is working when it isn't. I'll note this for future reviews.
✏️ Learnings added
Learnt from: paulgoodchild
Repo: GravityKit/gravity-forms-zero-spam PR: 58
File: includes/class-gf-zero-spam-shield-silent-captcha.php:294-304
Timestamp: 2026-03-24T19:14:05.840Z
Learning: In includes/class-gf-zero-spam-shield-silent-captcha.php, the `render_form_status_field()` method intentionally only renders a visible `<p class="description">` paragraph when `is_threshold_zero()` is true. Normal per-form help text is handled via GF tooltips to keep the UI uncluttered. The visible paragraph is a special-case warning so users don't mistakenly believe silentCAPTCHA is active when the Shield threshold is zero. Do not flag this conditional rendering as missing inline help — it is by design.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
🧠 Learnings used
Learnt from: mrcasual
Repo: GravityKit/gravity-forms-zero-spam PR: 49
File: includes/class-gf-zero-spam.php:287-314
Timestamp: 2026-02-27T00:27:08.941Z
Learning: In includes/ files for the Gravity Forms Zero Spam plugin, do not maintain the legacy fallback path in check_key_field() that uses GFAPI::add_note(), since GFCommon::set_spam_filter() has been available since Gravity Forms 2.7 (Jan 2023) and the minimum requirement is 2.5+. This path is effectively unused and adds unnecessary maintenance. Ensure the plugin requires Gravity Forms 2.5+ and rely on GFCommon::set_spam_filter() for spam filtering behavior instead of the legacy NF/Note-based fallback.
Learnt from: mrcasual
Repo: GravityKit/gravity-forms-zero-spam PR: 49
File: includes/class-email-rejection-field-settings.php:62-78
Timestamp: 2026-02-26T23:39:42.394Z
Learning: In WordPress plugins, plugin_dir_url(__DIR__) resolves to the plugin root when __DIR__ is a subdirectory (e.g., includes/). To get a subdirectory URL reliably, avoid plugin_dir_url(__DIR__) in such files. Instead, use plugin_dir_url(__FILE__) and append the relative subpath, or reference assets relative to the current file. Example: in includes/class-email-rejection-field-settings.php, to link to the includes/ subdirectory, use plugin_dir_url(__FILE__) . 'includes/'; or to reference a file inside the same subdirectory, use plugin_dir_url(__FILE__) . 'path/to/asset.ext'.
Learnt from: mrcasual
Repo: GravityKit/gravity-forms-zero-spam PR: 52
File: includes/class-gf-zero-spam.php:203-204
Timestamp: 2026-03-03T17:43:25.721Z
Learning: In includes/class-gf-zero-spam.php, the 24-hour TTL fallback token is intentional: used only when REST/AJAX endpoints are unreachable to avoid false positives on restrictive hosts. Tokens are HMAC-signed, bound to the form, and include a unique nonce; since this is spam prevention rather than authentication/CSRF, replay misuse is not a concern because bots rendering pages can obtain fresh tokens. This pattern can be applied when a system must degrade gracefully with a long-lived token only as a last resort, ensuring that nonces and signing are used to mitigate replay.
|
Thanks for the PR, Paul! We will review this soon! @paulgoodchild Please address the Coderabbit review items. |
Use Gravity Forms' real form-settings save hook and prefixed field names for the Shield per-form setting. Keep inherited forms missing when an unchanged save still matches the current effective value, so later global default changes continue to propagate. Also refresh the form status field docblock so it matches the current unavailable and threshold-warning behavior.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
includes/class-gf-zero-spam-shield-silent-captcha.php (1)
471-486: Consider removing unused$formparameter.The
$formparameter is passed but never used in this method. If there's no planned use for per-form context rules, you could simplify the signature:♻️ Optional cleanup
- private function is_submission_context_supported( $form, $entry ): bool { + private function is_submission_context_supported( $entry ): bool {And update the call site at line 193:
- if ( ! $this->is_submission_context_supported( $form, $entry ) ) { + if ( ! $this->is_submission_context_supported( $entry ) ) {Alternatively, if this is reserved for future per-form context rules, consider adding a brief
@paramdocblock noting the intent.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@includes/class-gf-zero-spam-shield-silent-captcha.php` around lines 471 - 486, The method is_submission_context_supported has an unused $form parameter—remove the parameter from the method signature and any PHPDoc for it, and update all call sites to stop passing the $form argument (so callers invoke is_submission_context_supported($entry) or no-arg if you also remove $entry); alternatively if you want to keep it for future use, add a brief `@param` docblock on is_submission_context_supported indicating it's reserved for future per-form rules and leave calls as-is. Ensure the unique symbol is_submission_context_supported is updated consistently across definition and callers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@includes/class-gf-zero-spam-shield-silent-captcha.php`:
- Around line 471-486: The method is_submission_context_supported has an unused
$form parameter—remove the parameter from the method signature and any PHPDoc
for it, and update all call sites to stop passing the $form argument (so callers
invoke is_submission_context_supported($entry) or no-arg if you also remove
$entry); alternatively if you want to keep it for future use, add a brief `@param`
docblock on is_submission_context_supported indicating it's reserved for future
per-form rules and leave calls as-is. Ensure the unique symbol
is_submission_context_supported is updated consistently across definition and
callers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 17bfe5be-d617-4d40-b732-b55de6abccdd
📒 Files selected for processing (1)
includes/class-gf-zero-spam-shield-silent-captcha.php
Clean up the Shield silentCAPTCHA integration by tightening the method contracts that are safe to tighten, documenting the public filter boundary that still needs defensive handling, removing a small unused helper/signature, and reusing the existing constant for Shield-facing labels. This keeps the behavior unchanged while making the class more precise, less noisy, and easier to review.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
includes/class-gf-zero-spam-shield-silent-captcha.php (2)
469-484: Minor style inconsistency withissetvsrgar.Line 483 uses
isset()while the rest of the class consistently usesrgar()for array access. Consider aligning for consistency.♻️ Optional: Consistent array access
- return !( isset( $entry['user_agent'] ) && 'API' === $entry['user_agent'] ); + return 'API' !== rgar( $entry, 'user_agent' );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@includes/class-gf-zero-spam-shield-silent-captcha.php` around lines 469 - 484, In is_submission_context_supported(), replace the isset check on $entry['user_agent'] with the project's rgar() helper for consistent array access: use rgar( $entry, 'user_agent' ) and compare it to 'API' instead of isset( $entry['user_agent'] ) && 'API' === $entry['user_agent']; update the return expression accordingly so it continues to return a boolean; this keeps the function (is_submission_context_supported) consistent with other uses of rgar across the class and with GFFormDisplay usage.
223-237: Consider returning$do_abortfor unsupported contexts.Line 229 returns
falseunconditionally when not in a Save & Continue context. While this works correctly (since$do_abortis already false/falsy at this point due to the early return at line 224), returning$do_abortwould be more consistent with the passthrough pattern used elsewhere in the class.♻️ Optional: Consistent passthrough pattern
if ( ! rgpost( 'gform_save' ) || GFCommon::is_preview() ) { - return false; + return $do_abort; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@includes/class-gf-zero-spam-shield-silent-captcha.php` around lines 223 - 237, The function maybe_abort_submission returns false unconditionally in unsupported contexts, which breaks the passthrough pattern; update the two early returns that currently return false (the checks for ! rgpost('gform_save') || GFCommon::is_preview() and ! $this->is_shield_enabled_for_form($form)) to return $do_abort instead so the original passthrough behavior (and any upstream abort state) is preserved; modify the return statements inside maybe_abort_submission() accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@includes/class-gf-zero-spam-shield-silent-captcha.php`:
- Around line 469-484: In is_submission_context_supported(), replace the isset
check on $entry['user_agent'] with the project's rgar() helper for consistent
array access: use rgar( $entry, 'user_agent' ) and compare it to 'API' instead
of isset( $entry['user_agent'] ) && 'API' === $entry['user_agent']; update the
return expression accordingly so it continues to return a boolean; this keeps
the function (is_submission_context_supported) consistent with other uses of
rgar across the class and with GFFormDisplay usage.
- Around line 223-237: The function maybe_abort_submission returns false
unconditionally in unsupported contexts, which breaks the passthrough pattern;
update the two early returns that currently return false (the checks for !
rgpost('gform_save') || GFCommon::is_preview() and !
$this->is_shield_enabled_for_form($form)) to return $do_abort instead so the
original passthrough behavior (and any upstream abort state) is preserved;
modify the return statements inside maybe_abort_submission() accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1dc30af2-de9b-4afa-8beb-b83e34e378cd
📒 Files selected for processing (1)
includes/class-gf-zero-spam-shield-silent-captcha.php
Add Shield silentCAPTCHA Integration
Summary
This PR adds an optional Shield silentCAPTCHA integration to Gravity Forms Zero Spam.
The goal is simple:
Where To Look
Start here:
includes/class-gf-zero-spam-addon.phpincludes/class-gf-zero-spam-shield-silent-captcha.phpclass-gf-zero-spam-addon.phponly does the wiring:class-gf-zero-spam-shield-silent-captcha.phpcontains the actual implementation:Useful methods to review first:
filter_entry_is_spam()maybe_abort_submission()normalize_plugin_settings()normalize_form_settings()get_effective_form_setting_value()resolve_shield_bot_verdict()render_plugin_status_field()render_form_status_field()Main Behavior
Runtime
Shield is only used as an extra spam signal.
true, we mark the entry as spamfalse,null, throws, or is unavailable, we do not block the submissionThis is intentionally fail-open. A broken or missing Shield integration should not interrupt form submissions.
Save and Continue follows the same rule:
true, we abort draft creationIf Gravity Forms cannot record the spam filter directly, the integration falls back to adding an entry note. That is why the class tracks flagged form IDs during the request.
Default And Override Logic
The plugin setting is the default.
This is based on whether the form key exists, not just whether the value is on or off.
That matters because:
0= explicit override off1= explicit override onThere is no separate inherit/override control in the UI. The current form toggle is still a single on/off control.
That means:
Settings And Save Handling
Both the global and per-form Shield settings use:
The hidden persist field exists so a disabled Shield toggle does not accidentally save as off just because the browser did not submit it.
The Shield status field is an HTML field, not a fake text field, so Gravity Forms does not try to validate it like a normal saved setting.
Admin UI Behavior
Global Settings
The global setting is labelled as the default for forms.
The message under it changes by Shield state:
Per-Form Settings
The per-form tooltip now explains two things:
The per-form inline help below the toggle is deliberately limited:
0, we show the same warning below the toggle that also appears in the tooltipWhy The Disable Script Exists
Gravity Forms renders toggle inputs with its own field names, and a disabled control may not always be enough on its own to preserve the stored value cleanly across the UI flow.
There is a very small inline vanilla JavaScript helper that:
This is only a UI safety layer. It does not change the stored value.
Review Notes
The main code paths worth checking are:
0shows the warning in the tooltip and below the per-form toggle.Important Callouts
truefrom Shield blocks anything.Summary by CodeRabbit