Skip to content
Open
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
24 changes: 24 additions & 0 deletions config/forms.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,30 @@

'delete_spam_submissions_after' => 30,

/*
|--------------------------------------------------------------------------
| Captcha
|--------------------------------------------------------------------------
|
| A captcha can be added to any form from its settings. Supported drivers
| are "turnstile", "hcaptcha", "recaptcha" (v2 checkbox), "recaptcha_v3"
| and "null". Captchas stay off until both of the keys have been set.
|
| The score and action only apply to reCAPTCHA v3. Visits scoring under
| the score are refused. The timeout is in seconds, and a provider that
| can't be reached in time counts as a failed check.
|
*/

'captcha' => [
'driver' => env('STATAMIC_CAPTCHA_DRIVER', 'turnstile'),
'site_key' => env('STATAMIC_CAPTCHA_SITE_KEY'),
'secret_key' => env('STATAMIC_CAPTCHA_SECRET_KEY'),
'score' => 0.5,
'action' => 'submit',
'timeout' => 5,
],

/*
|--------------------------------------------------------------------------
| Exporters
Expand Down
3 changes: 3 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@
'forgot_password_enter_email' => 'Enter your email address to receive a password reset link.',
'forgot_password_sent' => 'If an account with that email address exists, a password reset link has been sent.',
'form_configure_blueprint_instructions' => 'Choose from existing blueprints or create a new one.',
'form_configure_captcha_behavior_instructions' => 'What happens to submissions that fail the captcha. They can be refused with an error, or stored and marked as spam for review.',
'form_configure_captcha_instructions' => 'Add a captcha to this form, using the driver set up in `config/statamic/forms.php`.',
'form_configure_captcha_not_configured_instructions' => 'Set a captcha driver and its site and secret keys in `config/statamic/forms.php` to add a captcha to this form.',
'form_configure_email_attachments_instructions' => 'Attach files uploaded with the submission.',
'form_configure_email_body_instructions' => 'Type @ to include submitted values. Leave blank to list all submitted fields instead.',
'form_configure_email_from_instructions' => 'Leave blank to fall back to the site default.',
Expand Down
1 change: 1 addition & 0 deletions lang/en/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
'handle' => 'Must contain only lowercase letters and numbers with underscores as separators.',
'handle_starts_with_number' => 'Cannot start with a number.',
'slug' => 'Must contain only letters and numbers with dashes or underscores as separators.',
'captcha' => 'The captcha could not be verified. Please try again.',
'code_fieldtype_rulers' => 'This is invalid.',
'composer_package' => 'Must be a valid composer package name (eg. hasselhoff/kung-fury).',
'date_fieldtype_date_required' => 'Date is required.',
Expand Down
26 changes: 26 additions & 0 deletions src/Contracts/Forms/Captcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Statamic\Contracts\Forms;

interface Captcha
{
/**
* The HTML that goes inside the form: the widget and its script.
*
* The outermost element should carry a `data-captcha` attribute, so
* the form tag can tell the template has already rendered it.
*/
public function widget(): string;

/**
* Whether the visitor passed, given the token the widget put in the form.
*
* A failed check, or a provider that can't be reached, should return false.
*/
public function verify(?string $token, ?string $ip = null): bool;

/**
* The name of the form input the widget fills with its token.
*/
public function field(): string;
}
23 changes: 23 additions & 0 deletions src/Facades/Captcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Statamic\Facades;

use Illuminate\Support\Facades\Facade;
use Statamic\Forms\Captcha\CaptchaManager;

/**
* @method static \Statamic\Contracts\Forms\Captcha driver(?string $name = null)
* @method static \Statamic\Forms\Captcha\CaptchaManager extend(string $driver, \Closure $callback)
* @method static bool enabled()
* @method static \Statamic\Contracts\Forms\Captcha|null forForm(\Statamic\Contracts\Forms\Form $form)
* @method static string getDefaultDriver()
*
* @see CaptchaManager
*/
class Captcha extends Facade
{
protected static function getFacadeAccessor()
{
return CaptchaManager::class;
}
}
89 changes: 89 additions & 0 deletions src/Forms/Captcha/CaptchaManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Statamic\Forms\Captcha;

use Statamic\Contracts\Forms\Captcha;
use Statamic\Contracts\Forms\Form;
use Statamic\Support\Manager;

class CaptchaManager extends Manager
{
public function getDefaultDriver()
{
return $this->app['config']['statamic.forms.captcha.driver'] ?: 'null';
}

protected function getConfig($name)
{
return array_merge($this->app['config']['statamic.forms.captcha'] ?? [], ['driver' => $name]);
}

protected function invalidImplementationMessage($name)
{
return "Captcha driver [{$name}] is not defined.";
}

protected function invalidDriverMessage($driver, $name)
{
return "Captcha driver [{$driver}] is not defined.";
}

/**
* Whether a captcha has been set up. The built in drivers need both keys,
* so they stay off until both are set. Custom drivers decide for themselves
* what they need, so they're on as soon as they're the configured driver.
*/
public function enabled(): bool
{
$driver = $this->getDefaultDriver();

if ($driver === 'null') {
return false;
}

if (isset($this->customCreators[$driver])) {
return true;
}

$config = $this->getConfig($driver);

return filled($config['site_key'] ?? null) && filled($config['secret_key'] ?? null);
}

/**
* The captcha a form should use, or null when it shouldn't use one.
*/
public function forForm(Form $form): ?Captcha
{
if (! $form->get('captcha') || ! $this->enabled()) {
return null;
}

return $this->driver();
}

public function createNullDriver()
{
return new NullCaptcha;
}

public function createTurnstileDriver(array $config)
{
return new Turnstile($config);
}

public function createHcaptchaDriver(array $config)
{
return new Hcaptcha($config);
}

public function createRecaptchaDriver(array $config)
{
return new Recaptcha($config);
}

public function createRecaptchaV3Driver(array $config)
{
return new RecaptchaV3($config);
}
}
27 changes: 27 additions & 0 deletions src/Forms/Captcha/Hcaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Statamic\Forms\Captcha;

class Hcaptcha extends SiteverifyCaptcha
{
public function field(): string
{
return 'h-captcha-response';
}

protected function endpoint(): string
{
return 'https://api.hcaptcha.com/siteverify';
}

protected function payload(string $token, ?string $ip): array
{
return parent::payload($token, $ip) + ['sitekey' => $this->siteKey()];
}

public function widget(): string
{
return '<div class="h-captcha" data-captcha data-sitekey="'.$this->attr($this->siteKey()).'"></div>'
.'<script src="https://js.hcaptcha.com/1/api.js" async defer></script>';
}
}
23 changes: 23 additions & 0 deletions src/Forms/Captcha/NullCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Statamic\Forms\Captcha;

use Statamic\Contracts\Forms\Captcha;

class NullCaptcha implements Captcha
{
public function widget(): string
{
return '';
}

public function verify(?string $token, ?string $ip = null): bool
{
return true;
}

public function field(): string
{
return 'captcha';
}
}
25 changes: 25 additions & 0 deletions src/Forms/Captcha/Recaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Statamic\Forms\Captcha;

/**
* Google reCAPTCHA v2, the "I'm not a robot" checkbox.
*/
class Recaptcha extends SiteverifyCaptcha
{
public function field(): string
{
return 'g-recaptcha-response';
}

protected function endpoint(): string
{
return 'https://www.google.com/recaptcha/api/siteverify';
}

public function widget(): string
{
return '<div class="g-recaptcha" data-captcha data-sitekey="'.$this->attr($this->siteKey()).'"></div>'
.'<script src="https://www.google.com/recaptcha/api.js" async defer></script>';
}
}
46 changes: 46 additions & 0 deletions src/Forms/Captcha/RecaptchaV3.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace Statamic\Forms\Captcha;

/**
* Google reCAPTCHA v3. There's nothing to tick: Google scores each visit
* from 0 (a bot) to 1 (a person). Visits scoring under the "score" option
* are refused, and so are tokens made for another action, so a token
* minted for one purpose can't be replayed somewhere else.
*/
class RecaptchaV3 extends Recaptcha
{
public function widget(): string
{
$key = $this->attr($this->siteKey());

// Asks Google for a token as the form is submitted, then submits it again. The ready()
// stub is Google's documented way to queue the call until the async script has loaded.
return '<input type="hidden" name="g-recaptcha-response" value="" data-captcha data-captcha-recaptcha>'
.'<script src="https://www.google.com/recaptcha/api.js?render='.$key.'" async defer></script>'
.'<script>(function(){if(typeof grecaptcha==="undefined"){grecaptcha={}}grecaptcha.ready=grecaptcha.ready||function(cb){var c="___grecaptcha_cfg";window[c]=window[c]||{};(window[c].fns=window[c].fns||[]).push(cb)};document.querySelectorAll("[data-captcha-recaptcha]").forEach(function(input){var form=input.form;if(!form||form.dataset.captchaRecaptcha)return;form.dataset.captchaRecaptcha=1;form.addEventListener("submit",function(event){if(input.value)return;event.preventDefault();grecaptcha.ready(function(){grecaptcha.execute('.$this->js($this->siteKey()).',{action:'.$this->js($this->action()).'}).then(function(token){input.value=token;form.requestSubmit?form.requestSubmit():form.submit()})})})})})();</script>';
}

public function action(): string
{
$action = $this->config['action'] ?? null;

// Google allows letters, digits, slashes and underscores.
return is_string($action) && preg_match('#^[A-Za-z0-9/_]+$#', $action) ? $action : 'submit';
}

public function score(): float
{
$score = $this->config['score'] ?? null;

return is_numeric($score) ? (float) $score : 0.5;
}

protected function passed(array $result): bool
{
return parent::passed($result)
&& ($result['action'] ?? null) === $this->action()
&& is_numeric($result['score'] ?? null)
&& (float) $result['score'] >= $this->score();
}
}
Loading
Loading