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
36 changes: 15 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,29 +91,31 @@ itself is enabled.

## Logging

Turn on **Debug logging** (Forms → Settings → ALTCHA) to record every decision —
useful for verifying behaviour in staging or chasing a report. Each decision
writes one greppable line to the PHP error log:
Each protection decision is logged through **Gravity Forms' own logging
framework**. Enable the Gravity Forms *Logging* add-on, then under Forms →
Settings → **Logging** set *ALTCHA for Gravity Forms* to "Log errors only" or
"Log all messages" — you get a per-add-on, downloadable log file. Failures
(fail / blocked / spam) log at error level, passes at debug, so "errors only"
surfaces just the blocks. Sample messages:

```
[gravityforms-altcha] altcha: pass {"form":43}
[gravityforms-altcha] altcha: fail {"form":43,"reason":"replay"}
[gravityforms-altcha] rate_limit: blocked {"form":12}
[gravityforms-altcha] content_filter: spam {"form":7,"score":3,"signals":["keyword"]}
[gravityforms-altcha] email_validation: blocked {"form":7,"status":"undeliverable","disposable":false,"reason":"rejected_email","domain":"exmaple.com"}
altcha: pass {"form":43}
altcha: fail {"form":43,"reason":"replay"}
rate_limit: blocked {"form":12}
content_filter: spam {"form":7,"score":3,"signals":["keyword"]}
email_validation: blocked {"form":7,"status":"undeliverable","disposable":false,"reason":"rejected_email","domain":"example.com"}
```

`reason` for ALTCHA is `missing` / `invalid` / `replay`. **No PII is logged** —
IPs only ever appear as a salted hash, and emails as the domain only.

Route the records elsewhere (Sentry, Query Monitor, …) via the
`genero/gravityforms_altcha/log` action — and optionally drop the default
error-log line:
Every decision also fires the `genero/gravityforms_altcha/log` action, so you
can route records anywhere (Sentry, Query Monitor, …) independently of the GF
logger:

```php
remove_action('genero/gravityforms_altcha/log', ['Genero\GravityFormsAltcha\Logger', 'writeToErrorLog']);
add_action('genero/gravityforms_altcha/log', function (string $event, string $outcome, array $context) {
// ship $event/$outcome/$context to your sink
// ship $event / $outcome / $context to your sink
}, 10, 3);
```

Expand Down Expand Up @@ -220,14 +222,6 @@ add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [...
add_filter('genero/gravityforms_altcha/spam_score_threshold', fn () => 4);
```

### `genero/gravityforms_altcha/logging`

Force decision logging on or off regardless of the *Debug logging* setting:

```php
add_filter('genero/gravityforms_altcha/logging', fn () => defined('WP_DEBUG') && WP_DEBUG);
```

### `genero/gravityforms_altcha/bouncer_api_key`

Provide the Bouncer key in code instead of the `BOUNCER_API_KEY` env var (e.g.
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.4.0
* Version: 0.5.0
* Requires at least: 6.0
* Requires PHP: 8.2
* Author: Genero
Expand Down
45 changes: 16 additions & 29 deletions src/Logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,40 @@
namespace Genero\GravityFormsAltcha;

/**
* Lightweight, opt-in logging so each protection decision can be verified
* through the logs. Off by default; enabled by the "Debug logging" setting (or
* the `genero/gravityforms_altcha/logging` filter).
* Records each protection decision so behaviour can be verified.
*
* Every decision fires the `genero/gravityforms_altcha/log` action so it can be
* routed anywhere (Sentry, Query Monitor, …); a default listener writes a
* greppable line to the PHP error log (registered in {@see Plugin}).
* Every decision fires the `genero/gravityforms_altcha/log` action; the default
* listener (wired in {@see Plugin}) routes it into Gravity Forms' own logging
* framework, so it shows up under Forms → Settings → Logging with a per-add-on
* level (off / errors only / all) and a downloadable log file. Failures log at
* ERROR level, everything else at DEBUG, so "log errors only" surfaces just the
* blocks/failures.
*
* Privacy: callers pass only non-PII context — a form id, a salted IP hash, an
* email *domain* (never the full address or raw IP), spam signal names, etc.
* The action is also a clean extension point for routing elsewhere (Sentry,
* Query Monitor, …). Privacy: callers pass only non-PII context — a form id, a
* salted IP hash, an email domain — never a raw IP or full address.
*/
class Logger
{
public const HOOK = 'genero/gravityforms_altcha/log';

/**
* @param string $event e.g. altcha | rate_limit | content_filter | email_validation
* @param string $outcome e.g. pass | fail | blocked | allowed
* @param string $outcome e.g. pass | fail | blocked | spam | allowed
* @param array<string, mixed> $context non-PII detail
*/
public static function record(string $event, string $outcome, array $context = []): void
{
if (! self::enabled()) {
return;
}

do_action(self::HOOK, $event, $outcome, $context);
}

public static function enabled(): bool
{
/**
* Filters whether decisions are logged. Defaults to the "Debug logging"
* setting (wired in Plugin); return true/false to override.
*/
return (bool) apply_filters('genero/gravityforms_altcha/logging', false);
}

/**
* Default sink — a single greppable line per decision. Other listeners can
* be added on the same hook, or this one removed to fully take over routing.
*
* @param array<string, mixed> $context
* Whether an outcome represents a failure/block — logged at error level so
* it surfaces under GF's "log errors only".
*/
public static function writeToErrorLog(string $event, string $outcome, array $context = []): void
public static function isFailure(string $outcome): bool
{
error_log(self::format($event, $outcome, $context));
return in_array($outcome, ['fail', 'blocked', 'spam'], true);
}

/**
Expand All @@ -59,6 +46,6 @@ public static function format(string $event, string $outcome, array $context = [
{
$suffix = $context !== [] ? ' '.json_encode($context) : '';

return sprintf('[gravityforms-altcha] %s: %s%s', $event, $outcome, $suffix);
return sprintf('%s: %s%s', $event, $outcome, $suffix);
}
}
8 changes: 4 additions & 4 deletions 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.4.0';
public const VERSION = '0.5.0';

public const SLUG = 'gravityforms-altcha';

Expand Down Expand Up @@ -63,9 +63,9 @@ public function registerHooks(): void
return;
}

// Logging: drive the toggle from settings, and write the default sink.
add_filter('genero/gravityforms_altcha/logging', [Settings::class, 'loggingEnabled']);
add_action(Logger::HOOK, [Logger::class, 'writeToErrorLog'], 10, 3);
// Route decision logs into Gravity Forms' logging framework
// (Forms → Settings → Logging — per-add-on level + downloadable file).
add_action(Logger::HOOK, [Settings::class, 'logToGravityForms'], 10, 3);

Integration::register();
ChallengeEndpoint::register();
Expand Down
25 changes: 16 additions & 9 deletions src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,6 @@ public function plugin_settings_fields()
'tooltip' => esc_html__('Asks the visitor to correct a bad email address before submitting. Verifies via Bouncer — requires the BOUNCER_API_KEY environment variable, and sends the email to a third-party service. Fails open (never blocks) if the service is unavailable. Applies to all Gravity Forms.', 'gravityforms-altcha'),
'default_value' => false,
],
[
'name' => 'enable_logging',
'type' => 'toggle',
'label' => esc_html__('Debug logging', 'gravityforms-altcha'),
'tooltip' => esc_html__('Logs every ALTCHA, rate-limit, content-filter and email decision (pass or fail) to the PHP error log so you can verify behaviour. No raw IPs or full email addresses are logged. Leave off in normal operation.', 'gravityforms-altcha'),
'default_value' => false,
],
[
'name' => 'email_block',
'type' => 'checkbox',
Expand Down Expand Up @@ -227,9 +220,23 @@ public static function emailValidationEnabled(): bool
return (bool) self::get_instance()->get_plugin_setting('enable_email_validation');
}

public static function loggingEnabled(): bool
/**
* Routes a logged decision into Gravity Forms' logging framework, so it
* appears under Forms → Settings → Logging and respects the per-add-on level
* set there. Failures log at error level, everything else at debug.
*
* @param array<string, mixed> $context
*/
public static function logToGravityForms(string $event, string $outcome, array $context = []): void
{
return (bool) self::get_instance()->get_plugin_setting('enable_logging');
$addon = self::get_instance();
$message = Logger::format($event, $outcome, $context);

if (Logger::isFailure($outcome)) {
$addon->log_error($message);
} else {
$addon->log_debug($message);
}
}

/**
Expand Down
12 changes: 1 addition & 11 deletions test/mocked/LoggerRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,12 @@
namespace Genero\GravityFormsAltcha\Tests\Mocked;

use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Genero\GravityFormsAltcha\Logger;

class LoggerRecordTest extends MockedTestCase
{
public function test_records_nothing_when_disabled(): void
public function test_record_fires_the_log_action(): void
{
Functions\when('apply_filters')->justReturn(false);
Actions\expectDone(Logger::HOOK)->never();

Logger::record('altcha', 'pass', ['form' => 1]);
}

public function test_fires_the_log_action_when_enabled(): void
{
Functions\when('apply_filters')->justReturn(true);
Actions\expectDone(Logger::HOOK)->once()->with('rate_limit', 'blocked', ['form' => 1]);

Logger::record('rate_limit', 'blocked', ['form' => 1]);
Expand Down
14 changes: 12 additions & 2 deletions test/unit/LoggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@ class LoggerTest extends TestCase
{
public function test_format_without_context(): void
{
$this->assertSame('[gravityforms-altcha] altcha: pass', Logger::format('altcha', 'pass'));
$this->assertSame('altcha: pass', Logger::format('altcha', 'pass'));
}

public function test_format_with_context(): void
{
$this->assertSame(
'[gravityforms-altcha] altcha: fail {"form":43,"reason":"invalid"}',
'altcha: fail {"form":43,"reason":"invalid"}',
Logger::format('altcha', 'fail', ['form' => 43, 'reason' => 'invalid']),
);
}

public function test_failures_log_at_error_level(): void
{
foreach (['fail', 'blocked', 'spam'] as $outcome) {
$this->assertTrue(Logger::isFailure($outcome), $outcome);
}
foreach (['pass', 'allowed'] as $outcome) {
$this->assertFalse(Logger::isFailure($outcome), $outcome);
}
}
}
Loading