Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ a signed PBKDF2 proof that the browser quietly solved in the background.
* MIT-licensed end to end ([altcha-org/altcha](https://github.com/altcha-org/altcha-lib-php) for PHP, [altcha](https://www.npmjs.com/package/altcha) widget for JS, this plugin for the glue).
* No external API calls — challenges are issued and verified by your own WordPress install.
* No license keys, no quotas.
* Drop-in: enables itself automatically for every Gravity Form.
* Opt-in: enable globally for all forms, or per individual form.

## Requirements

Expand All @@ -34,10 +34,25 @@ wp plugin activate gravityforms-altcha
2. Upload to `wp-content/plugins/` (or via Plugins → Add New → Upload).
3. Activate.

## Settings

ALTCHA is **off by default**. Enable it one of two ways:

* **Globally** — Forms → Settings → **ALTCHA** → toggle *Enable for all forms*.
Every Gravity Form is then protected.
* **Per form** — a form's Settings → **ALTCHA** tab → toggle *Enable ALTCHA for
this form*. Use this when you only want protection on selected forms.

A form is protected when the global toggle is on **or** that form's own toggle
is on. Both are stored in the standard Gravity Forms settings (the global one as
the `gravityformsaddon_gravityforms-altcha_settings` option, the per-form one in
the form meta). The `genero/gravityforms_altcha/should_protect` filter can still
override the saved settings programmatically.

## How it works

1. **Form render** — the plugin injects a hidden `<altcha-widget>` web component
above the submit button on every Gravity Form.
1. **Form render** — when enabled for the form, the plugin injects a hidden
`<altcha-widget>` web component above the submit button.
2. **Browser-side proof-of-work** — the widget fetches a fresh challenge from
`/wp-json/genero/gravityforms-altcha/v1/challenge` (signed with a per-site
HMAC secret) and brute-forces a PBKDF2/SHA-256 derived-key match.
Expand All @@ -47,20 +62,21 @@ wp plugin activate gravityforms-altcha
reconstructs the challenge, and runs `altcha-org/altcha::verifySolution()`.
On failure the submission is rejected with a generic error message.

There is no admin UI, no per-form configuration, and no settings page —
everything customisable lives behind WordPress filters.
Day-to-day configuration lives in the admin UI (see [Settings](#settings)); the
filters below cover advanced overrides.

## Filters

### `genero/gravityforms_altcha/should_protect`

Skip protection on specific forms:
Override the saved settings — force protection on (or off) for specific forms
regardless of the global / per-form toggles:

```php
add_filter('genero/gravityforms_altcha/should_protect', function (bool $protect, array $form): bool {
// Don't run ALTCHA on internal preview-only forms.
if (in_array((int) $form['id'], [42, 43], true)) {
return false;
// Always protect the high-value lead form, whatever the toggles say.
if ((int) $form['id'] === 7) {
return true;
}
return $protect;
}, 10, 2);
Expand Down
2 changes: 1 addition & 1 deletion gravityforms-altcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Plugin Name: ALTCHA for Gravity Forms
* Plugin URI: https://github.com/generoi/gravityforms-altcha
* Description: Invisible ALTCHA spam protection for Gravity Forms — uses the MIT-licensed altcha-org/altcha PHP library and the ALTCHA widget web component to proof-of-work-verify every form submission with no user interaction.
* Version: 0.1.1
* Version: 0.2.0
* Requires at least: 6.0
* Requires PHP: 8.2
* Author: Genero
Expand Down
15 changes: 10 additions & 5 deletions src/Integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,17 @@ public function enqueueScripts(array $form, bool $is_ajax): void
private function shouldProtect(array $form): bool
{
/**
* Filters whether ALTCHA should protect this form. Default `true` — the
* plugin is opt-out, since the whole point is silent universal coverage.
* Sites that want per-form scoping can flip the default to `false` and
* enable on specific form IDs via this filter.
* Filters whether ALTCHA should protect this form. The default is
* derived from the add-on settings — opt-in via the global "Enable for
* all forms" toggle or the per-form toggle (see {@see Settings}). Pass
* a hard-coded boolean here to override the saved settings, e.g. to
* force protection on a specific form ID regardless of its toggle.
*/
return (bool) apply_filters('genero/gravityforms_altcha/should_protect', true, $form);
return (bool) apply_filters(
'genero/gravityforms_altcha/should_protect',
Settings::isEnabledForForm($form),
$form,
);
}

private function challenge(): Challenge
Expand Down
18 changes: 17 additions & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class Plugin
{
public const VERSION = '0.1.1';
public const VERSION = '0.2.0';

public const SLUG = 'gravityforms-altcha';

Expand Down Expand Up @@ -39,6 +39,7 @@ public function boot(): void
{
add_action('plugins_loaded', [$this, 'loadTextdomain']);
add_action('plugins_loaded', [$this, 'registerHooks'], 20);
add_action('gform_loaded', [$this, 'registerAddon'], 5);
}

public function loadTextdomain(): void
Expand Down Expand Up @@ -66,6 +67,21 @@ public function registerHooks(): void
ChallengeEndpoint::register();
}

/**
* Registers the Gravity Forms add-on that powers the global + per-form
* settings UI. Runs on `gform_loaded` so the add-on framework — and thus
* the `\GFAddOn` base class — is guaranteed to be available.
*/
public function registerAddon(): void
{
if (! method_exists('\GFForms', 'include_addon_framework')) {
return;
}

\GFForms::include_addon_framework();
\GFAddOn::register(Settings::class);
}

/**
* The HMAC key used to sign + verify challenges. Resolved from (in order):
* 1. The `genero/gravityforms_altcha/hmac_key` filter (lets consumers point
Expand Down
119 changes: 119 additions & 0 deletions src/Settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Genero\GravityFormsAltcha;

/**
* Gravity Forms add-on that exposes the ALTCHA configuration as admin UI:
*
* - A global "Enable for all forms" toggle under Forms → Settings → ALTCHA.
* Off by default — the plugin is opt-in.
* - A per-form "Enable ALTCHA for this form" toggle under each form's
* Settings → ALTCHA tab.
*
* A form is protected when either the global toggle is on, or that form's
* own toggle is on. See {@see self::isEnabledForForm()}.
*
* The class extends \GFAddOn, which only exists once Gravity Forms has loaded
* its add-on framework. It is therefore registered on `gform_loaded` (see
* {@see Plugin::registerAddon()}) and never autoloaded before that point.
*/
class Settings extends \GFAddOn
{
protected $_version = Plugin::VERSION;

protected $_min_gravityforms_version = '2.5';

protected $_slug = Plugin::SLUG;

protected $_title = 'ALTCHA for Gravity Forms';

protected $_short_title = 'ALTCHA';

private static ?Settings $instance = null;

public function __construct()
{
// Lets GFAddOn::update_path() derive _path/_url from the real plugin
// file rather than this class file inside src/.
$this->_full_path = Plugin::getInstance()->file;

parent::__construct();
}

public static function get_instance(): Settings
{
if (self::$instance === null) {
self::$instance = new self;
}

return self::$instance;
}

/**
* Global settings page (Forms → Settings → ALTCHA).
*
* @return array<int, array<string, mixed>>
*/
public function plugin_settings_fields()
{
return [
[
'title' => esc_html__('ALTCHA spam protection', 'gravityforms-altcha'),
'description' => esc_html__('Invisible proof-of-work spam protection for Gravity Forms — no captcha puzzles, no third-party requests.', 'gravityforms-altcha'),
'fields' => [
[
'name' => 'enable_all_forms',
'type' => 'toggle',
'label' => esc_html__('Enable for all forms', 'gravityforms-altcha'),
'tooltip' => esc_html__('When on, ALTCHA protects every Gravity Form on the site. When off, enable it per form from the form\'s ALTCHA settings tab.', 'gravityforms-altcha'),
'default_value' => false,
],
],
],
];
}

/**
* Per-form settings tab (form → Settings → ALTCHA).
*
* @param array<string, mixed> $form
* @return array<int, array<string, mixed>>
*/
public function form_settings_fields($form)
{
return [
[
'title' => esc_html__('ALTCHA spam protection', 'gravityforms-altcha'),
'fields' => [
[
'name' => 'enabled',
'type' => 'toggle',
'label' => esc_html__('Enable ALTCHA for this form', 'gravityforms-altcha'),
'tooltip' => esc_html__('Adds invisible ALTCHA spam protection to this form. Has no extra effect when "Enable for all forms" is turned on globally.', 'gravityforms-altcha'),
'default_value' => false,
],
],
],
];
}

/**
* Whether ALTCHA should protect a given form, based on the saved settings:
* the global "enable for all forms" toggle wins, otherwise the form's own
* per-form toggle decides. Both default off.
*
* @param array<string, mixed> $form
*/
public static function isEnabledForForm(array $form): bool
{
$addon = self::get_instance();

if ($addon->get_plugin_setting('enable_all_forms')) {
return true;
}

$formSettings = $addon->get_form_settings($form);

return is_array($formSettings) && ! empty($formSettings['enabled']);
}
}
Loading